Skip to content

Commit 53ed2c5

Browse files
authored
Merge pull request #470 from SyncNos/crh1
Refactor reader controls and improve menu layout
2 parents 5143b66 + 491e226 commit 53ed2c5

13 files changed

Lines changed: 553 additions & 235 deletions

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ YouTube 和 Bilibili 视频页可采集页面已加载的字幕 / 转录内容
8989
- **文章评论线程**:网页文章支持本地 threaded comments(详情页 + inpage 面板);可通过侧栏“附加选区”按钮把当前选区附加为引用(无选区会清空引用);点击/聚焦输入框不再自动附加;并随 Zip v2 备份恢复与文章同步链路保留。
9090
- **智能当前页抓取**:popup 会自动判断页面类型并触发“抓取 AI 对话”或“抓取文章”。
9191
- **图片缓存**:可选开启 AI 对话与网页文章图片本地缓存,支持在详情页手动补全历史 AI 对话图片。
92+
- **详情页更多菜单**:阅读设置、缓存图片和字数统计等次级操作统一收进右上角更多菜单,正文页头保持更简洁。
9293
- **反防盗链图片缓存**:网页文章图片命中规则时会自动缓存,即使关闭网页文章图片缓存开关也不会影响抓取主链路。
9394
- **Markdown 阅读风格**:在 Inpage 设置中选择 Medium / Notion / Book,控制 popup / app 里的 markdown 渲染样式。
9495
- **数据库备份 / 恢复**:完整导出和导入本地会话库(包含 `image_cache``article_comments` 评论线程),敏感信息(OAuth token 等)自动排除。

src/ui/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545

4646
- 优先复用现有 class 组合与 shared style(例如 `src/ui/shared/button-styles.ts`)。
4747
- 新增 UI 状态尽量用 token 或现有 utility,不要引入新的一套“局部常量”。
48+
- 详情页的次级操作优先收进右上角更多菜单,菜单内入口应使用竖排的 `menu-item` 风格,避免在正文 header 里散落多个并列按钮。
4849
- 在可交互元素上保留可见的 `focus-visible` 样式(避免键盘可访问性回退)。
4950

5051
## 4. 自检命令(手动)

src/ui/conversations/ConversationDetailPane.tsx

Lines changed: 97 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChevronLeft } from 'lucide-react';
1+
import { ChevronLeft, MoreHorizontal } from 'lucide-react';
22

33
import { ChatDetailView } from '@ui/conversations/views/ChatDetailView';
44
import { ArticleReaderView } from '@ui/conversations/views/ArticleReaderView';
@@ -9,7 +9,8 @@ import { useChatOutlineActiveIndex } from '@ui/conversations/chat-outline/useCha
99
import { t, formatConversationTitle } from '@i18n';
1010
import { useConversationsApp } from '@viewmodels/conversations/conversations-context';
1111
import { DetailHeaderActionBar } from '@ui/conversations/DetailHeaderActionBar';
12-
import { buttonTintClassName, headerButtonClassName } from '@ui/shared/button-styles';
12+
import { buttonMenuItemClassName, headerButtonClassName } from '@ui/shared/button-styles';
13+
import { MenuPopover } from '@ui/shared/MenuPopover';
1314
import { tooltipAttrs } from '@ui/shared/AppTooltip';
1415
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1516
import { countWordsFromMessages } from '@services/shared/word-count';
@@ -61,7 +62,6 @@ export function ConversationDetailPane({
6162
const openActions = safeActions.filter((action) => action.slot === 'open');
6263
const toolActions = safeActions.filter((action) => action.slot === 'tools');
6364

64-
const outlineButtonClass = buttonTintClassName();
6565
const headerIconButtonClass = headerButtonClassName();
6666
const kindView = conversationKinds.pick(selected as any)?.view ?? DEFAULT_VIEW;
6767
const isArticleRenderer = kindView.renderer === 'article';
@@ -160,6 +160,7 @@ export function ConversationDetailPane({
160160
const [urlEditing, setUrlEditing] = useState(false);
161161
const [urlDraft, setUrlDraft] = useState('');
162162
const [urlCleaning, setUrlCleaning] = useState(false);
163+
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
163164
const urlInputRef = useRef<HTMLInputElement | null>(null);
164165
const displayedUrl = String((selected as any)?.url || '').trim();
165166
const wordCount = useMemo(() => {
@@ -172,12 +173,32 @@ export function ConversationDetailPane({
172173
wordCount != null && Number.isFinite(wordCount)
173174
? `${wordCountLabel} ${Math.max(0, Math.floor(wordCount)).toLocaleString()}`
174175
: '';
176+
const hasReaderMoreMenuContent =
177+
readerFeatures.textLayout || readerFeatures.theme || readerFeatures.narration;
178+
const hasMoreMenuContent = Boolean(wordCountText) || toolActions.length > 0 || hasReaderMoreMenuContent;
179+
const moreMenuPanelClassName =
180+
'tw-w-[214px] tw-max-w-[min(214px,calc(100vw-28px))] tw-text-[var(--text-primary)]';
181+
const closeMoreMenu = useCallback(() => {
182+
setMoreMenuOpen(false);
183+
setReaderToolbarPortalTarget(null);
184+
}, []);
185+
const handleMoreMenuOpenChange = useCallback(
186+
(next: boolean) => {
187+
if (next) {
188+
setMoreMenuOpen(true);
189+
return;
190+
}
191+
closeMoreMenu();
192+
},
193+
[closeMoreMenu],
194+
);
175195

176196
useEffect(() => {
177197
setUrlEditing(false);
178198
setUrlDraft('');
179199
setUrlCleaning(false);
180-
}, [activeId]);
200+
closeMoreMenu();
201+
}, [activeId, selected?.id, closeMoreMenu]);
181202

182203
useEffect(() => {
183204
userMessageElByIdRef.current.clear();
@@ -353,18 +374,9 @@ export function ConversationDetailPane({
353374
}}
354375
aria-label={displayedUrl ? 'Edit URL' : 'Set URL'}
355376
title={displayedUrl || t('noLinkAvailable')}
356-
>
377+
>
357378
{displayedUrl || t('noLinkAvailable')}
358379
</button>
359-
{wordCountText ? (
360-
<span
361-
className="tw-inline-flex tw-items-center tw-rounded-[var(--radius-chip)] tw-border tw-border-[var(--border)] tw-px-2 tw-py-0.5 tw-text-[10px] tw-font-extrabold tw-text-[var(--text-secondary)]"
362-
aria-label={wordCountText}
363-
{...tooltipAttrs(wordCountText)}
364-
>
365-
{wordCountText}
366-
</span>
367-
) : null}
368380
</>
369381
)}
370382
</div>
@@ -376,27 +388,14 @@ export function ConversationDetailPane({
376388
</div>
377389
</div>
378390
<div className="tw-flex tw-shrink-0 tw-items-center tw-justify-end tw-gap-2 tw-whitespace-nowrap">
379-
{isArticleRenderer ? (
380-
<div
381-
ref={setReaderToolbarPortalTarget}
382-
className="tw-flex tw-items-center tw-gap-2"
383-
data-reader-header-toolbar-slot="true"
384-
/>
385-
) : null}
386-
<DetailHeaderActionBar
387-
actions={toolActions}
388-
buttonClassName={outlineButtonClass}
389-
menuTriggerLabel={t('detailHeaderToolsMenuLabel')}
390-
menuTriggerAriaLabel={t('detailHeaderToolsMenuAria')}
391-
menuAriaLabel={t('detailHeaderToolsMenuAria')}
392-
/>
393391
<DetailHeaderActionBar
394392
actions={openActions}
395393
buttonClassName={headerIconButtonClass}
396394
iconOnly
397395
menuTriggerLabel={t('detailHeaderOpenInMenuLabel')}
398396
menuTriggerAriaLabel={t('detailHeaderOpenInMenuAria')}
399397
menuAriaLabel={t('detailHeaderOpenInMenuAria')}
398+
className="tw-order-1"
400399
/>
401400

402401
{canOpenCommentsSidebar ? (
@@ -405,7 +404,7 @@ export function ConversationDetailPane({
405404
onClick={() => {
406405
onTriggerCommentsSidebar?.();
407406
}}
408-
className={headerButtonClassName()}
407+
className={[headerButtonClassName(), 'tw-order-2'].join(' ')}
409408
aria-label={commentsSidebarLabel}
410409
{...tooltipAttrs(commentsSidebarLabel)}
411410
aria-pressed={commentsSidebarOpen ? 'true' : 'false'}
@@ -424,6 +423,75 @@ export function ConversationDetailPane({
424423
<span className="tw-sr-only">{commentsSidebarLabel}</span>
425424
</button>
426425
) : null}
426+
{hasMoreMenuContent ? (
427+
<MenuPopover
428+
open={moreMenuOpen}
429+
onOpenChange={handleMoreMenuOpenChange}
430+
ariaLabel={t('moreButton')}
431+
side="bottom"
432+
align="end"
433+
panelMinWidth={214}
434+
panelClassName={moreMenuPanelClassName}
435+
className="tw-order-3"
436+
trigger={(triggerProps) => (
437+
<button
438+
{...triggerProps}
439+
data-detail-header-more-trigger="true"
440+
aria-label={t('moreButton')}
441+
className={headerIconButtonClass}
442+
>
443+
<MoreHorizontal size={14} strokeWidth={2} aria-hidden="true" />
444+
<span className="tw-sr-only">{t('moreButton')}</span>
445+
</button>
446+
)}
447+
>
448+
{moreMenuOpen ? (
449+
<div className="tw-flex tw-flex-col tw-gap-1">
450+
{hasReaderMoreMenuContent ? (
451+
<div
452+
ref={setReaderToolbarPortalTarget}
453+
className="tw-flex tw-flex-col tw-gap-1"
454+
data-reader-header-toolbar-slot="true"
455+
/>
456+
) : null}
457+
458+
{toolActions.length ? (
459+
<div
460+
className={[
461+
hasReaderMoreMenuContent ? 'tw-border-t tw-border-[var(--border)] tw-pt-1' : '',
462+
'tw-flex tw-flex-col tw-gap-1',
463+
]
464+
.filter(Boolean)
465+
.join(' ')}
466+
>
467+
<DetailHeaderActionBar
468+
actions={toolActions}
469+
buttonClassName={buttonMenuItemClassName()}
470+
showLabelAlways
471+
closeMenuOnActionTrigger={closeMoreMenu}
472+
className="tw-w-full"
473+
/>
474+
</div>
475+
) : null}
476+
477+
{wordCountText ? (
478+
<div
479+
className={[
480+
hasReaderMoreMenuContent || toolActions.length ? 'tw-border-t tw-border-[var(--border)] tw-pt-1' : '',
481+
'tw-px-2 tw-py-1 tw-text-[11px] tw-font-semibold tw-text-[var(--text-secondary)]',
482+
]
483+
.filter(Boolean)
484+
.join(' ')}
485+
data-detail-word-count-row="true"
486+
{...tooltipAttrs(wordCountText)}
487+
>
488+
{wordCountText}
489+
</div>
490+
) : null}
491+
</div>
492+
) : null}
493+
</MenuPopover>
494+
) : null}
427495
</div>
428496

429497
{isChatRenderer ? (
@@ -446,18 +514,6 @@ export function ConversationDetailPane({
446514
'tw-relative tw-pb-3 md:tw-pb-4',
447515
].join(' ')}
448516
>
449-
{wordCountText && urlEditing ? (
450-
<div className="tw-flex tw-justify-end">
451-
<span
452-
className="tw-inline-flex tw-items-center tw-rounded-[var(--radius-chip)] tw-border tw-border-[var(--border)] tw-px-2 tw-py-0.5 tw-text-[10px] tw-font-extrabold tw-text-[var(--text-secondary)]"
453-
aria-label={wordCountText}
454-
{...tooltipAttrs(wordCountText)}
455-
>
456-
{wordCountText}
457-
</span>
458-
</div>
459-
) : null}
460-
461517
{isArticleRenderer ? (
462518
<ArticleReaderView
463519
selected={selected}

src/ui/conversations/ConversationsScene.tsx

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -152,32 +152,30 @@ export function ConversationsScene({
152152
if (isNarrow) {
153153
if (narrowRoute === 'detail') {
154154
return (
155-
<div className="tw-flex tw-h-full tw-min-h-0 tw-w-full tw-min-w-0 tw-flex-col tw-bg-[var(--bg-card)] tw-text-[var(--text-primary)]">
156-
<div className="route-scroll tw-min-h-0 tw-flex-1 tw-overflow-auto tw-overflow-x-hidden tw-bg-[var(--bg-card)]">
157-
<ConversationDetailPane
158-
onBack={returnToList}
159-
onTriggerCommentsSidebar={
160-
canOpenCommentsFromDetail
161-
? () => {
162-
if (typeof onOpenCommentsExternally === 'function') {
163-
onOpenCommentsExternally();
164-
return;
165-
}
166-
if (!commentsSidebarRuntime) return;
167-
openComments();
168-
void commentsSidebarRuntime.sidebarController.open({
169-
focusComposer: true,
170-
source: narrowCommentsOpenSource,
171-
ensureContext: false,
172-
});
155+
<div className="route-scroll tw-flex tw-h-full tw-min-h-0 tw-w-full tw-min-w-0 tw-flex-col tw-overflow-auto tw-overflow-x-hidden tw-bg-[var(--bg-card)] tw-text-[var(--text-primary)]">
156+
<ConversationDetailPane
157+
onBack={returnToList}
158+
onTriggerCommentsSidebar={
159+
canOpenCommentsFromDetail
160+
? () => {
161+
if (typeof onOpenCommentsExternally === 'function') {
162+
onOpenCommentsExternally();
163+
return;
173164
}
174-
: undefined
175-
}
176-
onCommentsLocatorRootChange={(root) => {
177-
commentsSidebarRuntime?.setLocatorRoot(root);
178-
}}
179-
/>
180-
</div>
165+
if (!commentsSidebarRuntime) return;
166+
openComments();
167+
void commentsSidebarRuntime.sidebarController.open({
168+
focusComposer: true,
169+
source: narrowCommentsOpenSource,
170+
ensureContext: false,
171+
});
172+
}
173+
: undefined
174+
}
175+
onCommentsLocatorRootChange={(root) => {
176+
commentsSidebarRuntime?.setLocatorRoot(root);
177+
}}
178+
/>
181179
</div>
182180
);
183181
}

src/ui/conversations/DetailHeaderActionBar.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export type DetailHeaderActionBarProps = {
1111
actions: DetailHeaderAction[];
1212
buttonClassName: string;
1313
iconOnly?: boolean;
14+
showLabelAlways?: boolean;
15+
closeMenuOnActionTrigger?: () => void;
1416
triggerIcon?: ReactNode;
1517
menuTriggerLabel?: string;
1618
menuTriggerAriaLabel?: string;
@@ -22,6 +24,8 @@ export function DetailHeaderActionBar({
2224
actions,
2325
buttonClassName,
2426
iconOnly = false,
27+
showLabelAlways = false,
28+
closeMenuOnActionTrigger,
2529
triggerIcon,
2630
menuTriggerLabel,
2731
menuTriggerAriaLabel,
@@ -91,17 +95,18 @@ export function DetailHeaderActionBar({
9195
const icon = resolveActionIcon(action);
9296
const resolvedTriggerIcon = triggerIcon ||
9397
(iconOnly ? <ExternalLink size={16} strokeWidth={2} aria-hidden="true" /> : icon) || (
94-
<ExternalLink size={16} strokeWidth={2} aria-hidden="true" />
95-
);
98+
<ExternalLink size={16} strokeWidth={2} aria-hidden="true" />
99+
);
96100
const triggerButtonClassName = iconOnly ? buttonClassName : [buttonClassName, 'tw-text-[13px]'].join(' ');
97101
return (
98-
<div className={className || 'tw-flex tw-items-center tw-gap-2'}>
102+
<div className={['tw-flex tw-items-center tw-gap-2', className || ''].join(' ').trim()}>
99103
<button
100104
key={action.id}
101105
type="button"
102106
{...tooltipAttrs(action.label)}
103107
onClick={() => {
104108
void handleTrigger(action);
109+
closeMenuOnActionTrigger?.();
105110
}}
106111
className={triggerButtonClassName}
107112
aria-label={action.label}
@@ -113,10 +118,16 @@ export function DetailHeaderActionBar({
113118
) : (
114119
<span className="tw-inline-flex tw-items-center tw-gap-1.5">
115120
{resolvedTriggerIcon}
116-
<span className="tw-hidden md:tw-inline tw-whitespace-nowrap">{buttonLabel}</span>
121+
<span className={showLabelAlways ? 'tw-whitespace-nowrap' : 'tw-hidden md:tw-inline tw-whitespace-nowrap'}>
122+
{buttonLabel}
123+
</span>
117124
{busy && action.showBusyProgress ? (
118125
<span
119-
className="tw-hidden md:tw-inline-flex tw-h-1.5 tw-w-14 tw-overflow-hidden tw-rounded-full tw-bg-[var(--bg-sunken)]"
126+
className={
127+
showLabelAlways
128+
? 'tw-inline-flex tw-h-1.5 tw-w-14 tw-overflow-hidden tw-rounded-full tw-bg-[var(--bg-sunken)]'
129+
: 'tw-hidden md:tw-inline-flex tw-h-1.5 tw-w-14 tw-overflow-hidden tw-rounded-full tw-bg-[var(--bg-sunken)]'
130+
}
120131
aria-hidden="true"
121132
>
122133
<span className="tw-h-full tw-w-1/2 tw-animate-pulse tw-rounded-full tw-bg-[var(--accent)]" />
@@ -143,7 +154,7 @@ export function DetailHeaderActionBar({
143154
const menuButtonClass = buttonMenuItemClassName();
144155

145156
return (
146-
<div className={className || 'tw-flex tw-items-center tw-gap-2'}>
157+
<div className={['tw-flex tw-items-center tw-gap-2', className || ''].join(' ').trim()}>
147158
<MenuPopover
148159
open={menuOpen}
149160
onOpenChange={setMenuOpen}
@@ -165,7 +176,13 @@ export function DetailHeaderActionBar({
165176
<>
166177
<span className="tw-inline-flex tw-items-center tw-gap-1.5">
167178
{resolvedTriggerIcon}
168-
<span className="tw-hidden md:tw-inline tw-whitespace-nowrap tw-leading-none">{triggerLabel}</span>
179+
<span
180+
className={
181+
showLabelAlways ? 'tw-whitespace-nowrap tw-leading-none' : 'tw-hidden md:tw-inline tw-whitespace-nowrap tw-leading-none'
182+
}
183+
>
184+
{triggerLabel}
185+
</span>
169186
</span>
170187
<span
171188
className="tw-ml-1 tw-w-[14px] tw-text-center tw-text-[12px] tw-font-black tw-leading-none tw-text-[var(--text-secondary)]"
@@ -185,8 +202,9 @@ export function DetailHeaderActionBar({
185202
type="button"
186203
role="menuitem"
187204
onClick={() => {
188-
setMenuOpen(false);
189205
void handleTrigger(action);
206+
setMenuOpen(false);
207+
closeMenuOnActionTrigger?.();
190208
}}
191209
aria-disabled={action.disabled ? 'true' : undefined}
192210
disabled={busy || !!action.disabled}

0 commit comments

Comments
 (0)