Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 148 additions & 1 deletion packages/app-core/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -515,6 +516,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const [mode, setMode] = useState<PaneMode>('edit')
const [connectionsOpen, setConnectionsOpen] = useState(false)
const [outlineOpen, setOutlineOpen] = useState(false)
const [activeOutlineLine, setActiveOutlineLine] = useState<number | null>(null)
const [commentsOpen, setCommentsOpen] = useState(false)
const [commentDraft, setCommentDraft] = useState<CommentDraft | null>(null)
const [selectionCommentAction, setSelectionCommentAction] =
Expand Down Expand Up @@ -545,6 +547,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const ignoreEditorScrollRef = useRef(false)
const ignorePreviewScrollRef = useRef(false)
const pendingOutlineJumpLineRef = useRef<number | null>(null)
const activeOutlineLineRef = useRef<number | null>(null)
const activeOutlineFrameRef = useRef<number | null>(null)
const selectionActionFrameRef = useRef<number | null>(null)
const taskJumpHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/**
Expand Down Expand Up @@ -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<HTMLElement>('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
Expand Down Expand Up @@ -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]'
Expand Down Expand Up @@ -2171,7 +2314,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
/>
)}
{content && outlineOpen && !zenMode && (
<OutlinePanel note={content} onJump={jumpToOutlineLine} />
<OutlinePanel
note={content}
activeLine={activeOutlineLine}
onJump={jumpToOutlineLine}
/>
)}
</div>
{content &&
Expand Down
66 changes: 49 additions & 17 deletions packages/app-core/src/components/OutlinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLLIElement | null>(null)

// Reset the filter when the note changes so the outline reflects the
// new document from the top.
Expand All @@ -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 (
<section
aria-label="Outline"
Expand Down Expand Up @@ -61,22 +76,39 @@ export function OutlinePanel({ note, onJump }: Props): JSX.Element {
</div>
) : (
<ul className="flex flex-col">
{filtered.map((item) => (
<li key={`${item.line}-${item.from}`}>
<button
type="button"
onClick={() => onJump(item.line)}
title={`Jump to line ${item.line}`}
className="flex w-full min-w-0 items-center gap-2 rounded px-2 py-1 text-left text-sm text-ink-700 transition-colors hover:bg-paper-200 hover:text-ink-900"
style={{ paddingLeft: `${8 + (item.level - 1) * 12}px` }}
{filtered.map((item) => {
const isActive = activeLine != null && item.line === activeLine
return (
<li
key={`${item.line}-${item.from}`}
ref={isActive ? activeItemRef : undefined}
>
<span className="shrink-0 text-[10px] uppercase tracking-wide text-ink-400">
H{item.level}
</span>
<span className="min-w-0 flex-1 truncate">{item.text}</span>
</button>
</li>
))}
<button
type="button"
onClick={() => onJump(item.line)}
title={`Jump to line ${item.line}`}
aria-current={isActive ? 'true' : undefined}
className={[
'flex w-full min-w-0 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
isActive
? 'bg-accent/12 font-medium text-accent'
: 'text-ink-700 hover:bg-paper-200 hover:text-ink-900'
].join(' ')}
style={{ paddingLeft: `${8 + (item.level - 1) * 12}px` }}
>
<span
className={[
'shrink-0 text-[10px] uppercase tracking-wide',
isActive ? 'text-accent/75' : 'text-ink-400'
].join(' ')}
>
H{item.level}
</span>
<span className="min-w-0 flex-1 truncate">{item.text}</span>
</button>
</li>
)
})}
</ul>
)}
</div>
Expand Down
Loading