From a1aaaf62ae2e0ea4709ac4377758d108d40f9752 Mon Sep 17 00:00:00 2001 From: Roni Axelrad Date: Fri, 8 May 2026 07:33:04 -0400 Subject: [PATCH 1/2] Add Find functionality with FindBar component and update related translations --- package-lock.json | 4 +- src-tauri/src/lib.rs | 11 +- src/lib/MarkdownViewer.svelte | 41 ++- src/lib/components/FindBar.svelte | 501 +++++++++++++++++++++++++++++ src/lib/components/TitleBar.svelte | 28 ++ src/lib/utils/i18n.ts | 13 +- 6 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/FindBar.svelte diff --git a/package-lock.json b/package-lock.json index 35769e4..1558733 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "markpad", - "version": "2.6.6", + "version": "2.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markpad", - "version": "2.6.6", + "version": "2.6.8", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 846f0e5..572eec1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1028,6 +1028,12 @@ pub fn run() { .item(&PredefinedMenuItem::copy(app, None)?) .item(&PredefinedMenuItem::paste(app, None)?) .item(&PredefinedMenuItem::select_all(app, None)?) + .separator() + .item( + &MenuItemBuilder::with_id("menu-edit-find", "Find…") + .accelerator("CmdOrCtrl+F") + .build(app)?, + ) .build()?; let window_submenu = SubmenuBuilder::new(app, "Window") @@ -1139,7 +1145,10 @@ pub fn run() { if id == "check-updates" { let _ = window.emit("menu-check-updates", ()); - } else if id == "menu-app-quit" || id.starts_with("menu-file-") { + } else if id == "menu-app-quit" + || id.starts_with("menu-file-") + || id.starts_with("menu-edit-") + { let _ = window.emit(id, ()); } }) diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 7191728..f4ea4b6 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -18,6 +18,7 @@ import ContextMenu, { type ContextMenuItem } from './components/ContextMenu.svelte'; import Toc from './components/Toc.svelte'; import Toast from './components/Toast.svelte'; + import FindBar from './components/FindBar.svelte'; import { exportAsHtml as _exportHtml, exportAsPdf } from './utils/export'; import ZoomOverlay from './components/ZoomOverlay.svelte'; import { processMarkdownHtml } from './utils/markdown'; @@ -79,6 +80,9 @@ import { t } from './utils/i18n.js'; } | null>(null); let liveMode = $state(false); + let findOpen = $state(false); + let findBar = $state<{ reapply: () => void; clearHighlights: () => void } | null>(null); + let isDragging = $state(false); let dragTarget = $state<'editor' | 'preview' | null>(null); let editorPaneEl = $state(); @@ -331,6 +335,7 @@ import { t } from './utils/i18n.js'; $effect(() => { const _ = tabManager.activeTabId; showHome = false; + findOpen = false; }); function processHighlights(root: Element) { @@ -693,6 +698,16 @@ import { t } from './utils/i18n.js'; if (sanitizedHtml && markdownBody && !isEditing && hljs && renderMathInElement && mermaid) renderRichContent(); }); + // Re-apply find highlights after the preview HTML is replaced. The + // `bind:innerHTML={sanitizedHtml}` on the article wipes the DOM on every + // edit/render pass; without this, highlights vanish until the user + // re-types in the find bar. + $effect(() => { + const _ = sanitizedHtml; + if (!findOpen || !findBar) return; + tick().then(() => findBar?.reapply()); + }); + $effect(() => { // Depend on the ID and body existence to trigger restore const id = tabManager.activeTabId; @@ -2069,6 +2084,17 @@ import { t } from './utils/i18n.js'; e.preventDefault(); showSettings = !showSettings; } + // Ctrl/Cmd+F: open the preview find bar, but only when the editor + // (Monaco) doesn't have focus — Monaco ships its own native find + // widget and we don't want to fight it in Edit/Split mode. + if (cmdOrCtrl && !e.shiftKey && !e.altKey && key === 'f') { + const active = document.activeElement as Node | null; + const editorHasFocus = !!editorPaneEl && !!active && editorPaneEl.contains(active); + if (!editorHasFocus && markdownBody) { + e.preventDefault(); + findOpen = true; + } + } } function pushScrollHistory() { @@ -2283,6 +2309,11 @@ import { t } from './utils/i18n.js'; toggleEdit(); }), ); + unlisteners.push( + await listen('menu-edit-find', () => { + if (markdownBody) findOpen = true; + }), + ); unlisteners.push( await listen('menu-tab-rename', async (event) => { const tabId = event.payload as string; @@ -2577,6 +2608,7 @@ import { t } from './utils/i18n.js'; {theme} onSetTheme={(t) => (theme = t)} onopenSettings={() => (showSettings = true)} + onfind={() => { if (markdownBody) findOpen = true; }} oncloseTab={closeTabAndWindowIfLast} />
@@ -2620,6 +2652,7 @@ import { t } from './utils/i18n.js'; {theme} onSetTheme={(t) => (theme = t)} onopenSettings={() => (showSettings = true)} + onfind={() => { if (markdownBody) findOpen = true; }} oncloseTab={closeTabAndWindowIfLast} /> (theme = t)} onclose={() => (showSettings = false)} /> @@ -2675,7 +2708,13 @@ import { t } from './utils/i18n.js'; class="pane viewer-pane" class:active={!isEditing || isSplit} style="flex: {isSplit ? 1 - tabManager.activeTab.splitRatio : (!isEditing) ? 1 : 0}"> - + + +
+ import { fly } from 'svelte/transition'; + import { tick } from 'svelte'; + import { t } from '../utils/i18n.js'; + import type { LanguageCode } from '../utils/i18n.js'; + + let { + open = $bindable(false), + markdownBody, + language = 'en' as LanguageCode, + } = $props<{ + open: boolean; + markdownBody: HTMLElement | null; + language?: LanguageCode; + }>(); + + const FIND_MARK_CLASS = 'markpad-find-match'; + const FIND_MARK_ACTIVE_CLASS = 'active'; + const MAX_MATCHES = 5000; + const DEBOUNCE_MS = 80; + + let inputEl = $state(); + let query = $state(''); + let caseSensitive = $state(false); + let wholeWord = $state(false); + let matchCount = $state(0); + let activeIndex = $state(-1); + let truncated = $state(false); + let debounceTimer: ReturnType | null = null; + + function isHostElement(el: Element | null): boolean { + if (!el) return false; + const tag = el.tagName; + return ( + tag === 'CODE' || + tag === 'PRE' || + tag === 'SCRIPT' || + tag === 'STYLE' || + tag === 'NOSCRIPT' || + el.classList.contains(FIND_MARK_CLASS) + ); + } + + function isInsideHost(node: Node, root: Element): boolean { + let curr: Node | null = node.parentNode; + while (curr && curr !== root) { + if (curr.nodeType === Node.ELEMENT_NODE && isHostElement(curr as Element)) return true; + curr = curr.parentNode; + } + return false; + } + + export function clearHighlights() { + const root = markdownBody as HTMLElement | null; + if (!root) return; + const marks = Array.from( + root.querySelectorAll(`mark.${FIND_MARK_CLASS}`), + ) as HTMLElement[]; + for (const mark of marks) { + const parent = mark.parentNode; + if (!parent) continue; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + } + } + + function escapeForDisplay(s: string): string { + return s; + } + + function findInTextNode(text: string, needle: string): number[] { + // returns array of start indices for non-overlapping matches + const indices: number[] = []; + if (!needle) return indices; + const haystack = caseSensitive ? text : text.toLowerCase(); + const search = caseSensitive ? needle : needle.toLowerCase(); + let from = 0; + while (from <= haystack.length - search.length) { + const i = haystack.indexOf(search, from); + if (i === -1) break; + if (wholeWord) { + const before = i === 0 ? '' : haystack.charAt(i - 1); + const after = haystack.charAt(i + search.length); + const isBoundary = (c: string) => c === '' || !/[\p{L}\p{N}_]/u.test(c); + if (!isBoundary(before) || !isBoundary(after)) { + from = i + 1; + continue; + } + } + indices.push(i); + from = i + search.length; + } + return indices; + } + + function applyHighlights() { + if (!markdownBody) { + matchCount = 0; + activeIndex = -1; + truncated = false; + return; + } + clearHighlights(); + if (!query) { + matchCount = 0; + activeIndex = -1; + truncated = false; + return; + } + + const root = markdownBody; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node: Node) { + const text = (node as Text).nodeValue; + if (!text) return NodeFilter.FILTER_REJECT; + if (isInsideHost(node, root)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }); + + const targets: Text[] = []; + let node: Node | null; + while ((node = walker.nextNode())) targets.push(node as Text); + + let total = 0; + let hitCap = false; + const created: HTMLElement[] = []; + + outer: for (const textNode of targets) { + const text = textNode.nodeValue || ''; + const indices = findInTextNode(text, query); + if (indices.length === 0) continue; + + const parent = textNode.parentNode; + if (!parent) continue; + const doc = textNode.ownerDocument || document; + const frag = doc.createDocumentFragment(); + let cursor = 0; + for (const i of indices) { + if (total >= MAX_MATCHES) { + hitCap = true; + break outer; + } + if (i > cursor) frag.appendChild(doc.createTextNode(text.slice(cursor, i))); + const mark = doc.createElement('mark'); + mark.className = FIND_MARK_CLASS; + mark.textContent = text.slice(i, i + query.length); + frag.appendChild(mark); + created.push(mark); + cursor = i + query.length; + total++; + } + if (cursor < text.length) frag.appendChild(doc.createTextNode(text.slice(cursor))); + parent.replaceChild(frag, textNode); + } + + matchCount = total; + truncated = hitCap; + if (total === 0) { + activeIndex = -1; + } else { + activeIndex = 0; + setActive(0); + } + } + + function getMarks(): HTMLElement[] { + const root = markdownBody as HTMLElement | null; + if (!root) return []; + return Array.from( + root.querySelectorAll(`mark.${FIND_MARK_CLASS}`), + ) as HTMLElement[]; + } + + function setActive(index: number, scroll: boolean = true) { + const marks = getMarks(); + if (marks.length === 0) { + activeIndex = -1; + return; + } + const safe = ((index % marks.length) + marks.length) % marks.length; + marks.forEach((m, i) => m.classList.toggle(FIND_MARK_ACTIVE_CLASS, i === safe)); + activeIndex = safe; + if (scroll) { + marks[safe].scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + } + + export function next() { + if (matchCount === 0) return; + setActive(activeIndex + 1); + } + + export function prev() { + if (matchCount === 0) return; + setActive(activeIndex - 1); + } + + function scheduleApply() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + applyHighlights(); + }, DEBOUNCE_MS); + } + + export function reapply() { + // Public hook for parent: call after the preview HTML is replaced + // so existing matches survive across re-renders. + applyHighlights(); + } + + function close() { + clearHighlights(); + query = ''; + matchCount = 0; + activeIndex = -1; + truncated = false; + open = false; + } + + $effect(() => { + // Re-run search when query/options change. + // Touch reactive dependencies explicitly so $effect tracks them. + query; + caseSensitive; + wholeWord; + if (!open) return; + scheduleApply(); + }); + + $effect(() => { + if (!open) { + clearHighlights(); + return; + } + // On open, focus and select the input so typing replaces. + tick().then(() => { + inputEl?.focus(); + inputEl?.select(); + }); + }); + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) prev(); + else next(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'g') { + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) prev(); + else next(); + } + } + + function countLabel(): string { + if (!query) return ''; + if (matchCount === 0) return t('find.noMatches', language); + const total = truncated ? `${MAX_MATCHES}+` : String(matchCount); + return t('find.matchCount', language) + .replace('{{current}}', String(activeIndex + 1)) + .replace('{{total}}', total); + } + + +{#if open} + + +{/if} + + diff --git a/src/lib/components/TitleBar.svelte b/src/lib/components/TitleBar.svelte index 0ef0663..0ddfb70 100644 --- a/src/lib/components/TitleBar.svelte +++ b/src/lib/components/TitleBar.svelte @@ -51,6 +51,7 @@ theme = 'system', onSetTheme, onopenSettings, + onfind, } = $props<{ isFocused: boolean; isScrolled: boolean; @@ -88,6 +89,7 @@ theme?: string; onSetTheme?: (theme: string) => void; onopenSettings?: () => void; + onfind?: () => void; }>(); const appWindow = getCurrentWindow(); @@ -206,6 +208,12 @@ if (isMarkdown && !tabManager.activeTab?.isSplit) { list.push('edit'); } + // Find in preview: only meaningful when a preview is actually + // visible (view mode or split). In pure edit mode Monaco's own + // Ctrl+F handles search, so we hide the entry there. + if (isMarkdown && (!isEditing || tabManager.activeTab?.isSplit)) { + list.push('find'); + } list.push('zen'); list.push('tabs'); } @@ -548,6 +556,26 @@ {t('menu.tabs', currentLanguage).replace('{{action}}', settings.showTabs ? t('menu.hide', currentLanguage) : t('menu.show', currentLanguage) )} {modifier}+Shift+B + {:else if id === 'find'} + {:else if id === 'open_loc'}