Skip to content

Commit 076d7c7

Browse files
committed
fix: pin article outline to detail header
1 parent 4217ef5 commit 076d7c7

5 files changed

Lines changed: 69 additions & 28 deletions

File tree

src/ui/conversations/ConversationDetailPane.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export function ConversationDetailPane({
7979
const [outlineScrollRoot, setOutlineScrollRoot] = useState<Element | null>(null);
8080
const [optimisticActiveIndex, setOptimisticActiveIndex] = useState<number | null>(null);
8181
const [readerToolbarPortalTarget, setReaderToolbarPortalTarget] = useState<HTMLDivElement | null>(null);
82+
const [readerOutlinePortalTarget, setReaderOutlinePortalTarget] = useState<HTMLDivElement | null>(null);
8283
const outlineEntries = useMemo(
8384
() => (isChatRenderer && Array.isArray(detail?.messages) ? buildChatOutlineEntries(detail.messages) : []),
8485
[isChatRenderer, detail?.messages],
@@ -506,6 +507,14 @@ export function ConversationDetailPane({
506507
/>
507508
</div>
508509
) : null}
510+
511+
{isArticleRenderer ? (
512+
<div
513+
ref={setReaderOutlinePortalTarget}
514+
className="tw-absolute tw-right-3 tw-top-full tw-z-30 tw-pt-3 md:tw-right-4"
515+
data-reader-outline-toolbar-slot={outlineScrollRoot ? 'route-scroll' : 'viewport'}
516+
/>
517+
) : null}
509518
</header>
510519

511520
<div className={[containerPaddingClassName, 'tw-relative tw-pb-3 md:tw-pb-4'].join(' ')}>
@@ -520,6 +529,8 @@ export function ConversationDetailPane({
520529
setMessagesRootRef={setMessagesRootRef}
521530
readerFeatures={readerFeatures}
522531
readerToolbarPortalTarget={readerToolbarPortalTarget}
532+
readerOutlinePortalTarget={readerOutlinePortalTarget}
533+
outlineScrollRoot={outlineScrollRoot}
523534
/>
524535
) : (
525536
<ChatDetailView

src/ui/conversations/views/ArticleReaderView.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export type ReaderFeatures = { textLayout: boolean; theme: boolean; narration: b
4141
export type ArticleReaderViewProps = DetailViewSharedProps & {
4242
readerFeatures?: ReaderFeatures;
4343
readerToolbarPortalTarget?: HTMLElement | null;
44+
readerOutlinePortalTarget?: HTMLElement | null;
45+
outlineScrollRoot?: Element | null;
4446
};
4547

4648
// Body typography is driven entirely by the `--reader-*` CSS variables (P2-T3).
@@ -63,7 +65,7 @@ const READER_PROSE_CLASS = [
6365
const READER_COLUMN_STYLE: CSSProperties = { maxWidth: 'var(--reader-content-width)' };
6466
const READER_SHELL_CLASS = 'tw-flex tw-w-full tw-items-start tw-gap-4';
6567
const READER_MAIN_CLASS = 'tw-min-w-0 tw-flex-1 tw-max-w-full';
66-
const READER_RAIL_CLASS = 'tw-sticky tw-top-5 tw-flex-none tw-shrink-0 tw-self-start';
68+
const READER_RAIL_CLASS = 'tw-flex-none tw-shrink-0 tw-self-start';
6769

6870
/**
6971
* ArticleReaderView renders article / video conversations.
@@ -84,6 +86,8 @@ export function ArticleReaderView({
8486
setMessagesRootRef,
8587
readerFeatures,
8688
readerToolbarPortalTarget,
89+
readerOutlinePortalTarget,
90+
outlineScrollRoot,
8791
}: ArticleReaderViewProps) {
8892
// readerFeatures gates each toolbar piece; chat conversations pass all-false (or
8993
// omit it), so the toolbar renders nothing over the chat view.
@@ -103,7 +107,7 @@ export function ArticleReaderView({
103107
const [narrationSource, setNarrationSource] = useState('');
104108
const [sentenceDomRevision, setSentenceDomRevision] = useState(0);
105109
const sentenceCountRef = useRef(0);
106-
const outline = useArticleOutlineMinimap(outlineRoot);
110+
const outline = useArticleOutlineMinimap(outlineRoot, outlineScrollRoot);
107111
const narration = useReaderNarration(narrationSource, prefs.tts);
108112
const { activeSentence } = narration;
109113
const isNarrationEngaged = features.narration && narration.state !== 'idle';
@@ -440,6 +444,15 @@ export function ArticleReaderView({
440444
}, [features, prefs, readerToolbarPortalTarget, themeMode, toolbarNarration, update, updateThemeMode]);
441445

442446
const shouldRenderInlineRail = !!outlinePayload?.entries.length;
447+
const outlineRail = outlinePayload ? (
448+
<aside className={READER_RAIL_CLASS} data-reader-rail="article-rail">
449+
<ReaderToolbar outline={outlinePayload} />
450+
</aside>
451+
) : null;
452+
const outlineToolbarPortal = useMemo(() => {
453+
if (!readerOutlinePortalTarget || !outlineRail) return null;
454+
return createPortal(outlineRail, readerOutlinePortalTarget);
455+
}, [outlineRail, readerOutlinePortalTarget]);
443456

444457
const handleSentenceClick = useCallback(
445458
(event: ReactMouseEvent<HTMLDivElement>) => {
@@ -464,6 +477,7 @@ export function ArticleReaderView({
464477
return (
465478
<>
466479
{headerToolbar}
480+
{outlineToolbarPortal}
467481
<div
468482
className={READER_SHELL_CLASS}
469483
style={readerVars}
@@ -514,10 +528,8 @@ export function ArticleReaderView({
514528
)}
515529
</div>
516530

517-
{shouldRenderInlineRail ? (
518-
<aside className={READER_RAIL_CLASS} data-reader-rail="article-rail">
519-
<ReaderToolbar outline={outlinePayload} />
520-
</aside>
531+
{shouldRenderInlineRail && !readerOutlinePortalTarget ? (
532+
outlineRail
521533
) : null}
522534
</div>
523535
</>

src/ui/reader/ArticleOutlineMinimap.tsx

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ const STRIP_CLASS = 'tw-flex tw-flex-col tw-items-end tw-gap-2 tw-py-1 tw-pr-1';
3535
const PANEL_LIST_CLASS = 'tw-flex tw-max-h-[60vh] tw-flex-col tw-gap-1 tw-overflow-auto';
3636
const OUTLINE_REBUILD_SETTLE_MS = 180;
3737

38-
function readViewportRect(): ReaderOutlineCandidate['rect'] {
38+
function readViewportRect(scrollRoot?: Element | null): ReaderOutlineCandidate['rect'] {
39+
if (scrollRoot && typeof scrollRoot.getBoundingClientRect === 'function') {
40+
const rect = scrollRoot.getBoundingClientRect();
41+
return { top: rect.top, bottom: rect.bottom };
42+
}
3943
const view = globalThis.window ?? null;
4044
const height = Number(view?.innerHeight);
4145
if (!Number.isFinite(height) || height <= 0) return { top: 0, bottom: 0 };
@@ -121,28 +125,34 @@ function renderOutlineItem(
121125
);
122126
}
123127

124-
export function useArticleOutlineMinimap(root: HTMLElement | null): ArticleOutlineMinimapState {
128+
export function useArticleOutlineMinimap(
129+
root: HTMLElement | null,
130+
scrollRoot?: Element | null,
131+
): ArticleOutlineMinimapState {
125132
const [entries, setEntries] = useState<ReaderOutlineDomEntry[]>([]);
126133
const [activeIndex, setActiveIndex] = useState<number | null>(null);
127134
const entriesRef = useRef<ReaderOutlineDomEntry[]>([]);
128135
const activeIndexRef = useRef<number | null>(null);
129136
const rafRef = useRef<number | null>(null);
130137
const pendingKindRef = useRef<'entries' | 'active' | null>(null);
131138

132-
const syncActiveIndex = useCallback((currentEntries: ReaderOutlineDomEntry[] = entriesRef.current) => {
133-
const candidates = readCurrentCandidates(currentEntries);
134-
const nextActiveIndex = candidates.length
135-
? pickReaderOutlineActiveIndex({ viewportRect: readViewportRect(), candidates })
136-
: null;
137-
publishReaderPerformanceStats((current) => ({
138-
...current,
139-
outlineEntries: currentEntries.length,
140-
outlineActiveRecalcCount: current.outlineActiveRecalcCount + 1,
141-
}));
142-
if (nextActiveIndex === activeIndexRef.current) return;
143-
activeIndexRef.current = nextActiveIndex;
144-
setActiveIndex(nextActiveIndex);
145-
}, []);
139+
const syncActiveIndex = useCallback(
140+
(currentEntries: ReaderOutlineDomEntry[] = entriesRef.current) => {
141+
const candidates = readCurrentCandidates(currentEntries);
142+
const nextActiveIndex = candidates.length
143+
? pickReaderOutlineActiveIndex({ viewportRect: readViewportRect(scrollRoot), candidates })
144+
: null;
145+
publishReaderPerformanceStats((current) => ({
146+
...current,
147+
outlineEntries: currentEntries.length,
148+
outlineActiveRecalcCount: current.outlineActiveRecalcCount + 1,
149+
}));
150+
if (nextActiveIndex === activeIndexRef.current) return;
151+
activeIndexRef.current = nextActiveIndex;
152+
setActiveIndex(nextActiveIndex);
153+
},
154+
[scrollRoot],
155+
);
146156

147157
const rebuild = useCallback(() => {
148158
if (!root) {
@@ -231,7 +241,7 @@ export function useArticleOutlineMinimap(root: HTMLElement | null): ArticleOutli
231241
observer.observe(root, { childList: true, subtree: true, characterData: true });
232242
}
233243

234-
const scrollTarget: EventTarget = win || root;
244+
const scrollTarget: EventTarget = scrollRoot ?? win ?? root;
235245
scrollTarget.addEventListener?.('scroll', onScroll, { passive: true });
236246
win?.addEventListener?.('resize', onResize, { passive: true });
237247
schedule('entries');
@@ -248,7 +258,7 @@ export function useArticleOutlineMinimap(root: HTMLElement | null): ArticleOutli
248258
}
249259
pendingKindRef.current = null;
250260
};
251-
}, [rebuild, root, syncActiveIndex]);
261+
}, [rebuild, root, scrollRoot, syncActiveIndex]);
252262

253263
return { entries, activeIndex };
254264
}

tests/smoke/reader-mode-regression.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,9 @@ describe('reader mode regression', () => {
267267
expect(document.querySelector('[data-reader-header-toolbar-slot="true"]')).toBeTruthy();
268268
expect(document.querySelector('[data-reader-header-toolbar="true"]')).toBeTruthy();
269269
expect(await waitForSelector('[data-reader-rail-wrap="outline"]')).toBeTruthy();
270+
const articleOutlineSlot = document.querySelector('[data-reader-outline-toolbar-slot]');
271+
expect(articleOutlineSlot).toBeTruthy();
272+
expect(articleOutlineSlot?.querySelector('[data-reader-rail-wrap="outline"]')).toBeTruthy();
270273
expect(shell?.querySelector('[data-reader-header-toolbar="true"]')).toBeNull();
271274

272275
currentState.selectedConversation = {

tests/unit/article-reader-layout.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,13 @@ describe('ArticleReaderView layout', () => {
184184
expect(sentenceRoot?.style.maxWidth).toBe('var(--reader-content-width)');
185185
});
186186

187-
it('portals reader controls into the provided header target and keeps the inline rail for outline only', async () => {
187+
it('portals reader controls and the outline rail into their provided header targets', async () => {
188188
const headerTarget = document.createElement('div');
189189
headerTarget.setAttribute('data-reader-header-toolbar-slot', 'true');
190190
document.body.appendChild(headerTarget);
191+
const outlineTarget = document.createElement('div');
192+
outlineTarget.setAttribute('data-reader-outline-toolbar-slot', 'true');
193+
document.body.appendChild(outlineTarget);
191194
mocks.outlineEntries.push({
192195
index: 0,
193196
level: 1,
@@ -212,6 +215,8 @@ describe('ArticleReaderView layout', () => {
212215
setMessagesRootRef: vi.fn(),
213216
readerFeatures: { textLayout: true, theme: true, narration: true },
214217
readerToolbarPortalTarget: headerTarget,
218+
readerOutlinePortalTarget: outlineTarget,
219+
outlineScrollRoot: document.body,
215220
}),
216221
);
217222
});
@@ -221,9 +226,9 @@ describe('ArticleReaderView layout', () => {
221226
expect(headerTarget.querySelector('[data-testid="reader-header-toolbar"]')).toBeTruthy();
222227
const shell = document.querySelector('[data-reader-shell="article"]') as HTMLElement | null;
223228
expect(shell?.querySelector('[data-testid="reader-header-toolbar"]')).toBeNull();
224-
const rail = shell?.querySelector('[data-reader-rail="article-rail"]') as HTMLElement | null;
229+
expect(shell?.querySelector('[data-reader-rail="article-rail"]')).toBeNull();
230+
const rail = outlineTarget.querySelector('[data-reader-rail="article-rail"]') as HTMLElement | null;
225231
expect(rail).toBeTruthy();
226-
expect(rail?.className).toContain('tw-sticky');
227-
expect(rail?.className).toContain('tw-top-5');
232+
expect(rail?.querySelector('[data-testid="reader-toolbar"]')).toBeTruthy();
228233
});
229234
});

0 commit comments

Comments
 (0)