diff --git a/scripts/tocEditorJump.test.ts b/scripts/tocEditorJump.test.ts new file mode 100644 index 00000000..f36ff3e8 --- /dev/null +++ b/scripts/tocEditorJump.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const editor = readFileSync('src/lib/components/Editor.svelte', 'utf8'); +const toc = readFileSync('src/lib/components/Toc.svelte', 'utf8'); + +test('ToC editor jumps use the clicked heading source line, not duplicate heading text', () => { + assert.match(toc, /onjump\?: \(id: string, text: string, sourceLine: number \| null\) => void;/); + assert.match(toc, /const sourceLine = Number\(el\.dataset\.sourcepos\?\.match\(\/\^\(\\d\+\):\/\)\?\.\[1\]\);/); + assert.match(viewer, /onjump=\{\(id: string, text: string, sourceLine: number \| null\) => \{[\s\S]*?editorPane\.revealHeader\(sourceLine, text\);/); + assert.match(editor, /export function revealHeader\(sourceLine: number \| null, text: string\)/); + assert.match(editor, /const lineNumber = sourceLine \?\? 0;[\s\S]*?revealLineInCenterIfOutsideViewport\(lineNumber,/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 04c72b63..bdbc675c 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -110,7 +110,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu runEditorAction: (actionId: string, payload?: any) => void; undo: () => void; redo: () => void; - revealHeader: (text: string) => void; + revealHeader: (sourceLine: number | null, text: string) => void; triggerFind: () => void; } | null>(null); let liveMode = $state(false); @@ -3319,9 +3319,9 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu {collapsedHeaders} ontoggleFold={toggleFold} oncopyref={(text: string) => { const tab = tabManager.activeTab; const fn = tab?.path ? tab.path.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') || '' : ''; invoke('clipboard_write_text', { text: fn ? `[[${fn}#${text}]]` : `#${text}` }); }} - onjump={(id: string, text: string) => { + onjump={(id: string, text: string, sourceLine: number | null) => { if (isEditing && editorPane) { - editorPane.revealHeader(text); + editorPane.revealHeader(sourceLine, text); } }} oncontext={(e, item) => { diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 278f65dc..5284a852 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -1338,10 +1338,22 @@ dragCaretDecoration = editor.deltaDecorations(dragCaretDecoration, []); } - export function revealHeader(text: string) { + export function revealHeader(sourceLine: number | null, text: string) { if (!editor) return; const model = editor.getModel(); if (!model) return; + const lineNumber = sourceLine ?? 0; + if (Number.isInteger(lineNumber) && lineNumber > 0) { + editor.revealLineInCenterIfOutsideViewport(lineNumber, monaco.editor.ScrollType.Smooth); + editor.setSelection({ + startLineNumber: lineNumber, + startColumn: 1, + endLineNumber: lineNumber, + endColumn: model.getLineMaxColumn(lineNumber), + }); + editor.focus(); + return; + } const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`^#+\\s+.*${escapedText}.*$`, "m"); diff --git a/src/lib/components/Toc.svelte b/src/lib/components/Toc.svelte index 028e3ad0..5833973c 100644 --- a/src/lib/components/Toc.svelte +++ b/src/lib/components/Toc.svelte @@ -10,7 +10,7 @@ ontoggleFold?: (id: string) => void; oncopyref?: (text: string) => void; oncontext?: (e: MouseEvent, item: TocItem) => void; - onjump?: (id: string, text: string) => void; + onjump?: (id: string, text: string, sourceLine: number | null) => void; onshowTooltip?: (e: MouseEvent, text: string, shortcut?: string, align?: 'top' | 'right' | 'left' | 'below') => void; onhideTooltip?: () => void; }>(); @@ -188,7 +188,8 @@ scrollTocIntoView(); const item = items.find(i => i.id === id); - if (item) onjump?.(id, item.text); + const sourceLine = Number(el.dataset.sourcepos?.match(/^(\d+):/)?.[1]); + if (item) onjump?.(id, item.text, Number.isInteger(sourceLine) ? sourceLine : null); // highlight element persistently until scroll if (activeTargetEl) activeTargetEl.classList.remove('toc-target-active');