diff --git a/package.json b/package.json index dd75e7df..442553db 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts scripts/frontMatterDisclosure.test.ts", "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts", - "test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/reloadOpenToolbar.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts scripts/toolbarCustomizationWiring.test.ts", + "test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/reloadOpenToolbar.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts scripts/toolbarCustomizationWiring.test.ts scripts/findSelectionWiring.test.ts", "test:settings-scroll": "node --test --import tsx scripts/previewScrollSync.test.ts scripts/toolbarCustomizationWiring.test.ts", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/scripts/findSelectionWiring.test.ts b/scripts/findSelectionWiring.test.ts new file mode 100644 index 00000000..672c05b2 --- /dev/null +++ b/scripts/findSelectionWiring.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const markdownViewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const findBar = readFileSync('src/lib/components/FindBar.svelte', 'utf8'); +const editor = readFileSync('src/lib/components/Editor.svelte', 'utf8'); +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { scripts: Record }; + +test('preview find opens before seeding a selected query', () => { + assert.match(markdownViewer, /async function triggerFindAction\(\)/); + assert.match(markdownViewer, /if \(previewSelection\) findBar\?\.setQuery\(previewSelection\.text, previewSelection\.range\);[\s\S]*findOpen = true;[\s\S]*await tick\(\)/); +}); + +test('preview find only seeds selections contained in one text node', () => { + assert.match(markdownViewer, /range\.startContainer !== range\.endContainer/); + assert.match(markdownViewer, /range\.startContainer\.nodeType !== Node\.TEXT_NODE/); +}); + +test('editor relies on Monaco native selection seeding', () => { + assert.doesNotMatch(editor, /seedSearchStringFromSelection/); +}); + +test('workflow tests include selected-text search coverage', () => { + assert.match(packageJson.scripts['test:workflows'], /scripts\/findSelectionWiring\.test\.ts/); +}); + +test('preview find active match uses range overlap instead of same-node identity', () => { + assert.match(findBar, /compareBoundaryPoints\(Range\.START_TO_END, matchRange\) > 0/); + assert.match(findBar, /compareBoundaryPoints\(Range\.END_TO_START, matchRange\) < 0/); + assert.doesNotMatch(findBar, /pendingActiveRange\.startContainer !== textNode/); + assert.doesNotMatch(findBar, /pendingActiveRange\.endContainer !== textNode/); +}); + +test('selection-seeded preview find preserves scroll even when range matching falls back', () => { + assert.match(findBar, /pendingActiveTop = activeRange \? getRangeTop\(activeRange\) : null/); + assert.match(findBar, /requestedActiveIndex \?\? closestActiveIndex \?\? preferredActiveIndex \?\? 0/); + assert.match(findBar, /setActive\(nextActiveIndex, !shouldPreserveScroll && !options\.preserveScroll\)/); +}); + +test('selection match index survives a preview rerender before highlights apply', () => { + assert.match(findBar, /query = value;[\s\S]*pendingActiveIndex = activeRange \? getRangeMatchIndex\(activeRange\) : null/); + assert.match(findBar, /let requestedActiveIndex = pendingActiveIndex/); + assert.match(findBar, /preferredActiveIndex: appliedQuery === query \? activeIndex : null/); +}); + +test('preview find reapply preserves active index without scrolling', () => { + assert.match(findBar, /applyHighlights\(\{ preserveScroll: true, preferredActiveIndex: activeIndex \}\)/); + assert.match(markdownViewer, /if \(findOpen\) \{[\s\S]*await tick\(\);[\s\S]*findBar\?\.reapply\(\);[\s\S]*\}/); +}); + +test('opening preview find does not trigger a duplicate render reapply', () => { + assert.match(markdownViewer, /const _ = sanitizedHtml;[\s\S]*untrack\(\(\) => \{[\s\S]*if \(!findOpen \|\| !findBar\) return;[\s\S]*tick\(\)\.then\(\(\) => findBar\?\.reapply\(\)\);[\s\S]*\}\)/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 1d77a3c5..65534695 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -112,20 +112,44 @@ import { t } from './utils/i18n.js'; let liveMode = $state(false); let findOpen = $state(false); - let findBar = $state<{ reapply: () => void; clearHighlights: () => void } | null>(null); + let findBar = $state<{ + reapply: () => void; + clearHighlights: () => void; + focus: () => void; + setQuery: (value: string, activeRange?: Range | null) => void; + } | null>(null); + + function getPreviewSelection(): { text: string; range: Range } | null { + const selection = window.getSelection(); + if (!markdownBody || !selection || selection.isCollapsed || selection.rangeCount === 0) return null; + if (!markdownBody.contains(selection.anchorNode) || !markdownBody.contains(selection.focusNode)) { + return null; + } + const range = selection.getRangeAt(0); + if (range.startContainer !== range.endContainer || range.startContainer.nodeType !== Node.TEXT_NODE) { + return null; + } + const text = selection.toString().trim(); + if (!text) return null; + return { text, range: range.cloneRange() }; + } // Decide where Cmd/Ctrl+F should land based on what's visible and where // focus is. Used by both the JS keydown handler (Win/Linux + macOS in-page // shortcut) and the macOS native menu listener (which fires Cmd+F via the // Edit menu accelerator and bypasses the JS keydown path). - function triggerFindAction() { + async function triggerFindAction() { const active = document.activeElement as Node | null; const editorHasFocus = !!editorPaneEl && !!active && editorPaneEl.contains(active); const previewVisible = !isEditing || !!tabManager.activeTab?.isSplit; if (editorHasFocus || !previewVisible) { editorPane?.triggerFind?.(); } else if (markdownBody) { + const previewSelection = getPreviewSelection(); + if (previewSelection) findBar?.setQuery(previewSelection.text, previewSelection.range); findOpen = true; + await tick(); + if (!previewSelection) findBar?.focus(); } } @@ -141,6 +165,20 @@ import { t } from './utils/i18n.js'; toasts.push({ id, message, type }); } + async function copyMarkdownDocument() { + const tab = tabManager.activeTab; + const path = tab?.path || currentFile; + if (!tab || showHome || (path && !hasMarkdownLinkExtension(path))) return; + + try { + await invoke('clipboard_write_text', { text: tab.rawContent || '' }); + addToast(t('toast.markdownCopied', settings.language), 'info'); + } catch (error) { + console.error('Failed to copy markdown:', error); + addToast(t('toast.failedToCopyMarkdown', settings.language), 'error'); + } + } + // --- Auto-save bookkeeping (see saveContent + auto-save $effect below) --- // Per-tab debounce timers so switching tabs cannot kill another tab's pending save. const autoSaveTimers = new Map>(); @@ -984,6 +1022,11 @@ import { t } from './utils/i18n.js'; throwOnError: false, }); } + + if (findOpen) { + await tick(); + findBar?.reapply(); + } } $effect(() => { @@ -1015,8 +1058,10 @@ import { t } from './utils/i18n.js'; // re-types in the find bar. $effect(() => { const _ = sanitizedHtml; - if (!findOpen || !findBar) return; - tick().then(() => findBar?.reapply()); + untrack(() => { + if (!findOpen || !findBar) return; + tick().then(() => findBar?.reapply()); + }); }); $effect(() => { @@ -2381,6 +2426,14 @@ import { t } from './utils/i18n.js'; e.preventDefault(); getCurrentWindow().close(); } + if (cmdOrCtrl && e.shiftKey && !e.altKey && key === 'c') { + const active = document.activeElement as Node | null; + const editorHasFocus = !!editorPaneEl && !!active && editorPaneEl.contains(active); + if (!editorHasFocus) { + e.preventDefault(); + copyMarkdownDocument(); + } + } if (cmdOrCtrl && !e.shiftKey && !e.altKey && (code === 'Backslash' || code === 'IntlBackslash')) { e.preventDefault(); if (tabManager.activeTabId) toggleSplitView(tabManager.activeTabId, true); @@ -2453,7 +2506,7 @@ import { t } from './utils/i18n.js'; const editorHasFocus = !!editorPaneEl && !!active && editorPaneEl.contains(active); if (!editorHasFocus) { e.preventDefault(); - triggerFindAction(); + void triggerFindAction(); } } } @@ -2715,7 +2768,7 @@ import { t } from './utils/i18n.js'; ); unlisteners.push( await listen('menu-edit-find', () => { - triggerFindAction(); + void triggerFindAction(); }), ); unlisteners.push( @@ -3015,6 +3068,7 @@ import { t } from './utils/i18n.js'; onSetTheme={(t) => (theme = t)} onopenSettings={() => (showSettings = true)} onfind={triggerFindAction} + oncopyMarkdown={copyMarkdownDocument} oncloseTab={closeTabAndWindowIfLast} />
@@ -3060,6 +3114,7 @@ import { t } from './utils/i18n.js'; onSetTheme={(t) => (theme = t)} onopenSettings={() => (showSettings = true)} onfind={triggerFindAction} + oncopyMarkdown={copyMarkdownDocument} canGoBack={canGoBackInFileHistory} canGoForward={canGoForwardInFileHistory} onback={() => navigateFileHistory('back')} @@ -3108,6 +3163,7 @@ import { t } from './utils/i18n.js'; onnextTab={() => tabManager.cycleTab('next')} onprevTab={() => tabManager.cycleTab('prev')} onundoClose={handleUndoCloseTab} + oncopyMarkdown={copyMarkdownDocument} onscrollsync={handleEditorScrollSync} /> {/if}
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index b384ab51..d67c0df1 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -33,6 +33,7 @@ onnextTab, onprevTab, onundoClose, + oncopyMarkdown, onscrollsync, zoomLevel = $bindable(100), theme = "system", @@ -51,6 +52,7 @@ onnextTab?: () => void; onprevTab?: () => void; onundoClose?: () => void; + oncopyMarkdown?: () => void; onscrollsync?: (position: ScrollSyncPosition) => void; zoomLevel?: number; isSplit?: boolean; @@ -744,6 +746,15 @@ }, }); + editor.addAction({ + id: "copy-markdown-document", + label: t('menu.copyMarkdown', uiLanguage), + keybindings: [ + monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyC, + ], + run: () => oncopyMarkdown?.(), + }); + const wheelListener = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); diff --git a/src/lib/components/FindBar.svelte b/src/lib/components/FindBar.svelte index 8f6425f4..f686e055 100644 --- a/src/lib/components/FindBar.svelte +++ b/src/lib/components/FindBar.svelte @@ -27,6 +27,10 @@ let activeIndex = $state(-1); let truncated = $state(false); let debounceTimer: ReturnType | null = null; + let pendingActiveRange: Range | null = null; + let pendingActiveTop: number | null = null; + let pendingActiveIndex: number | null = null; + let appliedQuery = ''; function isHostElement(el: Element | null): boolean { if (!el) return false; @@ -93,7 +97,67 @@ return indices; } - function applyHighlights() { + function matchesRange(range: Range, textNode: Text, index: number): boolean { + const doc = textNode.ownerDocument || document; + const matchRange = doc.createRange(); + try { + matchRange.setStart(textNode, index); + matchRange.setEnd(textNode, index + query.length); + return range.compareBoundaryPoints(Range.START_TO_END, matchRange) > 0 + && range.compareBoundaryPoints(Range.END_TO_START, matchRange) < 0; + } catch { + return false; + } finally { + matchRange.detach(); + } + } + + function getRangeMatchIndex(range: Range): number | null { + const root = markdownBody; + if (!root || !query) return null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node: Node) { + const text = (node as Text).nodeValue; + if (!text || isInsideHost(node, root)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }); + let total = 0; + let textNode = walker.nextNode() as Text | null; + while (textNode) { + for (const index of findInTextNode(textNode.nodeValue || '', query)) { + if (total >= MAX_MATCHES) return null; + if (matchesRange(range, textNode, index)) return total; + total++; + } + textNode = walker.nextNode() as Text | null; + } + return null; + } + + function getRangeTop(range: Range): number | null { + const rect = range.getBoundingClientRect(); + if (rect.height > 0 || rect.width > 0) return rect.top; + const firstRect = range.getClientRects()[0]; + return firstRect ? firstRect.top : null; + } + + function getClosestMarkIndex(top: number): number | null { + const marks = getMarks(); + if (marks.length === 0) return null; + let closestIndex = 0; + let closestDistance = Number.POSITIVE_INFINITY; + for (const [index, mark] of marks.entries()) { + const distance = Math.abs(mark.getBoundingClientRect().top - top); + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + } + return closestIndex; + } + + function applyHighlights(options: { preserveScroll?: boolean; preferredActiveIndex?: number | null } = {}) { if (!markdownBody) { matchCount = 0; activeIndex = -1; @@ -105,6 +169,7 @@ matchCount = 0; activeIndex = -1; truncated = false; + appliedQuery = ''; return; } @@ -120,6 +185,9 @@ let total = 0; let hitCap = false; + let requestedActiveIndex = pendingActiveIndex; + const shouldPreserveScroll = pendingActiveRange !== null; + const requestedTop = pendingActiveTop; // Walk and process in a single pass. We advance the walker BEFORE // mutating each text node so its internal currentNode never points @@ -144,6 +212,9 @@ breakOut = true; break; } + if (requestedActiveIndex === null && pendingActiveRange && matchesRange(pendingActiveRange, textNode, i)) { + requestedActiveIndex = total; + } if (i > cursor) frag.appendChild(doc.createTextNode(text.slice(cursor, i))); const mark = doc.createElement('mark'); mark.className = FIND_MARK_CLASS; @@ -160,13 +231,26 @@ textNode = next; } + pendingActiveRange = null; + pendingActiveTop = null; + pendingActiveIndex = null; matchCount = total; truncated = hitCap; + appliedQuery = query; if (total === 0) { activeIndex = -1; } else { - activeIndex = 0; - setActive(0); + const closestActiveIndex = requestedTop === null ? null : getClosestMarkIndex(requestedTop); + const preferredActiveIndex = + options.preferredActiveIndex !== null + && options.preferredActiveIndex !== undefined + && options.preferredActiveIndex >= 0 + && options.preferredActiveIndex < total + ? options.preferredActiveIndex + : null; + const nextActiveIndex = requestedActiveIndex ?? closestActiveIndex ?? preferredActiveIndex ?? 0; + activeIndex = nextActiveIndex; + setActive(nextActiveIndex, !shouldPreserveScroll && !options.preserveScroll); } } @@ -217,14 +301,34 @@ // (e.g. parent flips `open` to false on tab switch without // going through close()). if (!open) return; - applyHighlights(); + applyHighlights({ preferredActiveIndex: appliedQuery === query ? activeIndex : null }); }, DEBOUNCE_MS); } export function reapply() { // Public hook for parent: call after the preview HTML is replaced // so existing matches survive across re-renders. - applyHighlights(); + applyHighlights({ preserveScroll: true, preferredActiveIndex: activeIndex }); + } + + function focusInput() { + tick().then(() => { + inputEl?.focus(); + inputEl?.select(); + }); + } + + export function focus() { + focusInput(); + } + + export function setQuery(value: string, activeRange?: Range | null) { + pendingActiveRange = activeRange ? activeRange.cloneRange() : null; + pendingActiveTop = activeRange ? getRangeTop(activeRange) : null; + query = value; + pendingActiveIndex = activeRange ? getRangeMatchIndex(activeRange) : null; + if (open) scheduleApply(); + focusInput(); } function close() { @@ -257,10 +361,7 @@ return; } // On open, focus and select the input so typing replaces. - tick().then(() => { - inputEl?.focus(); - inputEl?.select(); - }); + focusInput(); }); function handleKeydown(e: KeyboardEvent) { diff --git a/src/lib/components/TitleBar.svelte b/src/lib/components/TitleBar.svelte index 9383894f..0916a29f 100644 --- a/src/lib/components/TitleBar.svelte +++ b/src/lib/components/TitleBar.svelte @@ -59,6 +59,7 @@ onSetTheme, onopenSettings, onfind, + oncopyMarkdown, } = $props<{ isFocused: boolean; isScrolled: boolean; @@ -102,6 +103,7 @@ onSetTheme?: (theme: string) => void; onopenSettings?: () => void; onfind?: () => void; + oncopyMarkdown?: () => void; }>(); const appWindow = getCurrentWindow(); @@ -195,6 +197,11 @@ } }); + let isMarkdown = $derived.by(() => { + const ext = currentFile ? currentFile.split('.').pop()?.toLowerCase() || '' : 'md'; + return ['md', 'markdown', 'mdown', 'mkd', 'txt'].includes(ext); + }); + let visibleActionIds = $derived.by(() => { const list: string[] = []; @@ -203,9 +210,6 @@ list.push('forward'); if (currentFile) list.push('reload'); - const ext = currentFile ? currentFile.split('.').pop()?.toLowerCase() || '' : 'md'; - const isMarkdown = ['md', 'markdown', 'mdown', 'mkd', 'txt'].includes(ext); - if (isMarkdown) { list.push('toc'); list.push('fullWidth'); @@ -430,6 +434,21 @@ {/if} {#if currentFile !== '' || (tabManager.activeTab && tabManager.activeTab.content)}
+ {#if isMarkdown} + + {/if}