From 7322bf2a8f898212c31a32eac53f4e56a956e562 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 5 May 2026 09:17:14 -0500 Subject: [PATCH] feat(outline): highlight active heading on scroll Track the topmost-visible heading from the editor's scroll surface (or the preview's, in preview mode) and tint the matching row in the outline panel so users can see at a glance which section they're in. The probe sits ~25% down the viewport so a heading lights up as soon as it enters the upper portion of the visible area. --- .../app-core/src/components/EditorPane.tsx | 149 +++++++++++++++++- .../app-core/src/components/OutlinePanel.tsx | 66 ++++++-- 2 files changed, 197 insertions(+), 18 deletions(-) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 463758eb..d21d0a48 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -87,6 +87,7 @@ import { readImageBlockDragPayload } from '../lib/image-block-dnd' import { useSettledMarkdown } from '../lib/use-rendered-markdown' +import { parseOutline } from '../lib/outline' import { ArchiveIcon, ArrowUpRightIcon, @@ -515,6 +516,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const [mode, setMode] = useState('edit') const [connectionsOpen, setConnectionsOpen] = useState(false) const [outlineOpen, setOutlineOpen] = useState(false) + const [activeOutlineLine, setActiveOutlineLine] = useState(null) const [commentsOpen, setCommentsOpen] = useState(false) const [commentDraft, setCommentDraft] = useState(null) const [selectionCommentAction, setSelectionCommentAction] = @@ -545,6 +547,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const ignoreEditorScrollRef = useRef(false) const ignorePreviewScrollRef = useRef(false) const pendingOutlineJumpLineRef = useRef(null) + const activeOutlineLineRef = useRef(null) + const activeOutlineFrameRef = useRef(null) const selectionActionFrameRef = useRef(null) const taskJumpHighlightTimerRef = useRef | null>(null) /** @@ -789,6 +793,84 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return () => window.removeEventListener('zen:outline-jump', handler) }, [isActive, jumpToOutlineLine]) + // Outline items derived from the current note body — line numbers + // here are 1-based to match `parseOutline` and the editor's doc API. + const outlineItems = useMemo( + () => parseOutline(content?.body ?? ''), + [content?.body] + ) + + const setActiveOutlineLineSafely = useCallback((line: number | null) => { + if (activeOutlineLineRef.current === line) return + activeOutlineLineRef.current = line + setActiveOutlineLine(line) + }, []) + + const computeActiveFromEditor = useCallback(() => { + const view = viewRef.current + if (!view) return + if (outlineItems.length === 0) { + setActiveOutlineLineSafely(null) + return + } + // Probe ~25% down the viewport (capped) so a heading is considered + // active once it scrolls into the upper portion of the visible area + // — not only after it has scrolled past the very top edge. + const rect = view.scrollDOM.getBoundingClientRect() + const probeY = rect.top + Math.min(140, rect.height * 0.25) + const pos = view.posAtCoords({ x: rect.left + 8, y: probeY }) + if (pos == null) { + setActiveOutlineLineSafely(null) + return + } + const probeLine = view.state.doc.lineAt(pos).number + let activeLine: number | null = null + for (const item of outlineItems) { + if (item.line <= probeLine) activeLine = item.line + else break + } + setActiveOutlineLineSafely(activeLine) + }, [outlineItems, setActiveOutlineLineSafely]) + + const computeActiveFromPreview = useCallback(() => { + const dom = previewScrollRef.current + if (!dom) return + if (outlineItems.length === 0) { + setActiveOutlineLineSafely(null) + return + } + const headings = dom.querySelectorAll('h1, h2, h3, h4, h5, h6') + if (headings.length === 0) { + setActiveOutlineLineSafely(null) + return + } + // Match the editor probe: a heading counts as active once it sits + // inside the upper ~25% band of the preview viewport. + const rect = dom.getBoundingClientRect() + const threshold = rect.top + Math.min(140, rect.height * 0.25) + let activeIndex = -1 + for (let i = 0; i < headings.length; i++) { + if (headings[i].getBoundingClientRect().top <= threshold) activeIndex = i + else break + } + if (activeIndex < 0) { + setActiveOutlineLineSafely(null) + return + } + const item = outlineItems[Math.min(activeIndex, outlineItems.length - 1)] + setActiveOutlineLineSafely(item?.line ?? null) + }, [outlineItems, setActiveOutlineLineSafely]) + + const scheduleActiveOutlineUpdate = useCallback((compute: () => void) => { + if (activeOutlineFrameRef.current != null) { + cancelAnimationFrame(activeOutlineFrameRef.current) + } + activeOutlineFrameRef.current = requestAnimationFrame(() => { + activeOutlineFrameRef.current = null + compute() + }) + }, []) + // Mount / unmount the CodeMirror view via a callback ref on the host // div. The callback identity is stable so React only invokes it on // mount / unmount — `content` is read from `useStore.getState()` at @@ -1977,6 +2059,67 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { focusEditorNormalMode() }, [applyPaneMode, mode]) + // Track the topmost-visible heading and surface it as the active + // outline item. We listen on whichever surface is the user's scroll + // target for the current mode — split mode follows the editor since + // that's where typing happens. + useEffect(() => { + if (!outlineOpen) { + setActiveOutlineLineSafely(null) + return + } + if (mode === 'preview') return + const view = viewRef.current + if (!view) return + const dom = view.scrollDOM + const handler = (): void => scheduleActiveOutlineUpdate(computeActiveFromEditor) + dom.addEventListener('scroll', handler, { passive: true }) + handler() + return () => { + dom.removeEventListener('scroll', handler) + if (activeOutlineFrameRef.current != null) { + cancelAnimationFrame(activeOutlineFrameRef.current) + activeOutlineFrameRef.current = null + } + } + }, [ + computeActiveFromEditor, + content?.path, + mode, + outlineItems, + outlineOpen, + scheduleActiveOutlineUpdate, + setActiveOutlineLineSafely + ]) + + useEffect(() => { + if (!outlineOpen) return + if (mode !== 'preview') return + const dom = previewScrollRef.current + if (!dom) return + const handler = (): void => scheduleActiveOutlineUpdate(computeActiveFromPreview) + dom.addEventListener('scroll', handler, { passive: true }) + // Wait a frame so the preview has had a chance to render headings + // for the current `previewMarkdown` before we measure. + const initial = requestAnimationFrame(() => computeActiveFromPreview()) + return () => { + cancelAnimationFrame(initial) + dom.removeEventListener('scroll', handler) + if (activeOutlineFrameRef.current != null) { + cancelAnimationFrame(activeOutlineFrameRef.current) + activeOutlineFrameRef.current = null + } + } + }, [ + computeActiveFromPreview, + content?.path, + mode, + outlineItems, + outlineOpen, + previewMarkdown, + scheduleActiveOutlineUpdate + ]) + const paneFrameClass = [ 'relative flex min-h-0 min-w-0 flex-1 flex-col', isActive ? '' : 'opacity-[0.98]' @@ -2171,7 +2314,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { /> )} {content && outlineOpen && !zenMode && ( - + )} {content && diff --git a/packages/app-core/src/components/OutlinePanel.tsx b/packages/app-core/src/components/OutlinePanel.tsx index 9baf4d0d..9f5ca126 100644 --- a/packages/app-core/src/components/OutlinePanel.tsx +++ b/packages/app-core/src/components/OutlinePanel.tsx @@ -6,20 +6,28 @@ * Jumping is delegated to the host via `onJump(line)` because the * panel doesn't own a CodeMirror view; EditorPane targets its local * `viewRef` so clicks work even when this isn't the active pane. + * + * `activeLine` (when provided) marks which heading the host considers + * "currently visible" based on scroll position. The matching row is + * highlighted and auto-scrolled into view so users can orient at a + * glance as they scroll through long notes. */ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { NoteContent } from '@shared/ipc' import { parseOutline } from '../lib/outline' interface Props { note: NoteContent + /** 1-based line of the heading the host wants visually marked. */ + activeLine?: number | null /** Jump the host pane's editor to the given 1-based line. */ onJump: (line: number) => void } -export function OutlinePanel({ note, onJump }: Props): JSX.Element { +export function OutlinePanel({ note, activeLine, onJump }: Props): JSX.Element { const items = useMemo(() => parseOutline(note.body), [note.body]) const [query, setQuery] = useState('') + const activeItemRef = useRef(null) // Reset the filter when the note changes so the outline reflects the // new document from the top. @@ -31,6 +39,13 @@ export function OutlinePanel({ note, onJump }: Props): JSX.Element { return items.filter((item) => item.text.toLowerCase().includes(q)) }, [items, query]) + // Keep the active heading in view as the user scrolls. `nearest` + // avoids fighting them when the row is already visible. + useEffect(() => { + if (activeLine == null) return + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [activeLine]) + return (
) : (
    - {filtered.map((item) => ( -
  • - -
  • - ))} + + + ) + })}
)}