Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions scripts/findSelectionWiring.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };

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]*\}\)/);
});
68 changes: 62 additions & 6 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand All @@ -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<string, ReturnType<typeof setTimeout>>();
Expand Down Expand Up @@ -984,6 +1022,11 @@ import { t } from './utils/i18n.js';
throwOnError: false,
});
}

if (findOpen) {
await tick();
findBar?.reapply();
}
}

$effect(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2453,7 +2506,7 @@ import { t } from './utils/i18n.js';
const editorHasFocus = !!editorPaneEl && !!active && editorPaneEl.contains(active);
if (!editorHasFocus) {
e.preventDefault();
triggerFindAction();
void triggerFindAction();
}
}
}
Expand Down Expand Up @@ -2715,7 +2768,7 @@ import { t } from './utils/i18n.js';
);
unlisteners.push(
await listen('menu-edit-find', () => {
triggerFindAction();
void triggerFindAction();
}),
);
unlisteners.push(
Expand Down Expand Up @@ -3015,6 +3068,7 @@ import { t } from './utils/i18n.js';
onSetTheme={(t) => (theme = t)}
onopenSettings={() => (showSettings = true)}
onfind={triggerFindAction}
oncopyMarkdown={copyMarkdownDocument}
oncloseTab={closeTabAndWindowIfLast} />
<div class="loading-screen">
<svg class="spinner" viewBox="0 0 50 50">
Expand Down Expand Up @@ -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')}
Expand Down Expand Up @@ -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}
</div>
Expand Down
11 changes: 11 additions & 0 deletions src/lib/components/Editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
onnextTab,
onprevTab,
onundoClose,
oncopyMarkdown,
onscrollsync,
zoomLevel = $bindable(100),
theme = "system",
Expand All @@ -51,6 +52,7 @@
onnextTab?: () => void;
onprevTab?: () => void;
onundoClose?: () => void;
oncopyMarkdown?: () => void;
onscrollsync?: (position: ScrollSyncPosition) => void;
zoomLevel?: number;
isSplit?: boolean;
Expand Down Expand Up @@ -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();
Expand Down
Loading