feat: search and replace

This commit is contained in:
Steffen Becker committed 2026-06-28 00:05:37 +02:00
1 parent 98e7ee2458
commit 3b299809e1
7 files changed
+490 -2

No files matched your search

+44 -1
View File
@@ -5,6 +5,7 @@
import EditorComponent from './lib/components/Editor.svelte';
import Toolbar from './lib/components/Toolbar.svelte';
import ToolbarExpanded from './lib/components/ToolbarExpanded.svelte';
import FindReplaceBar from './lib/components/FindReplaceBar.svelte';
import { buildOdt, deriveFilename } from './lib/export/odt';
import { exportPdf, printPdf } from './lib/export/pdf';
import { supportsFsAccess, saveOdt, saveAsOdt, openOdt } from './lib/export/saveFile';
@@ -40,6 +41,24 @@
let activeEditor = $derived(hfActive ? hfEditor : editor);
let activeTick = $derived(hfActive ? hfTick : tick);
// Find & Replace bar (searchReplace.ts). Targets the body editor; positioned just
// below the toolbar region (toolbarRegionH tracks its height, expanded or not).
let findOpen = $state(false);
let findMode: 'find' | 'replace' = $state('find');
let findNonce = $state(0);
let toolbarRegionH = $state(0);
function openFind(mode: 'find' | 'replace') {
findMode = mode;
findOpen = true;
findNonce++;
}
function closeFind() {
findOpen = false;
editor?.commands.clearSearch();
}
// Word/character counts for the status-bar counter. Reading `tick` makes these
// recompute on every body transaction (incl. selection changes), so they stay
// live without subscribing to ProseMirror directly.
@@ -441,6 +460,17 @@
e.preventDefault();
handlePrintPdf();
}
// Ctrl/Cmd+F → Find, Ctrl/Cmd+H → Find & Replace (suppress the browser's own find).
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
e.preventDefault();
openFind('find');
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'h' || e.key === 'H')) {
e.preventDefault();
openFind('replace');
}
// Escape closes the bar (when it isn't handled inside an input).
if (e.key === 'Escape' && findOpen) closeFind();
}
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
@@ -471,7 +501,7 @@
</script>
<main>
<div class="toolbar-region">
<div class="toolbar-region" bind:clientHeight={toolbarRegionH}>
<div class="toolbar-clip" id="primary-toolbar" bind:this={toolbarClipEl} onwheel={onToolbarWheel}>
<div class="toolbar-stack" bind:this={toolbarStackEl} style="transform: translateX(-{tbScroll}px);">
<header class:expanded={toolbarExpanded}>
@@ -706,6 +736,11 @@
{pageMargins}
orientation={pageOrientation}
/>
{#if findOpen && editor}
<div class="find-bar-anchor" style="top: {toolbarRegionH + 8}px;">
<FindReplaceBar {editor} {tick} mode={findMode} focusNonce={findNonce} onClose={closeFind} />
</div>
{/if}
<footer class="statusbar">
<div class="sb-left">
<span>Page {currentPage} of {numPages}</span>
@@ -775,6 +810,14 @@
height: 100%;
}
/* Find & Replace bar: floats at the top-right of the editing area, just under the
toolbar (top is set inline from the toolbar height). Below the toolbar's z-index. */
.find-bar-anchor {
position: fixed;
right: 1.5rem;
z-index: 190;
}
/* Toolbar scroll region. A stacking context (z-index) so the whole toolbar — incl.
its dropdowns, which live inside the translated stack — paints above the editor
and its overlays. */
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { onMount } from 'svelte';
import { getSearchState } from '../editor/extensions/searchReplace';
let { editor, tick, mode, focusNonce, onClose }:
{ editor: Editor | null; tick: number; mode: 'find' | 'replace'; focusNonce: number; onClose: () => void } = $props();
let findText = $state('');
let replaceText = $state('');
let matchCase = $state(false);
let wholeWord = $state(false);
let showReplace = $state(false); // set from `mode` in the focusNonce effect below
let findInput = $state<HTMLInputElement | null>(null);
// Live match count / current index, recomputed on every editor transaction.
let results = $derived.by(() => {
void tick;
return editor ? getSearchState(editor.state) : { count: 0, current: -1, term: '' };
});
function applySearch() {
editor?.commands.setSearch({ term: findText, matchCase, wholeWord });
editor?.commands.scrollToCurrent();
}
function next() { editor?.commands.findNext(); }
function prev() { editor?.commands.findPrevious(); }
function replaceOne() { editor?.commands.replaceCurrent(replaceText); }
function replaceEvery() { editor?.commands.replaceAll(replaceText); }
function onFindKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? prev() : next(); }
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
}
function onReplaceKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
}
function toggleCase() { matchCase = !matchCase; applySearch(); }
function toggleWord() { wholeWord = !wholeWord; applySearch(); }
// Prefill from the current selection (single line) on first open.
onMount(() => {
if (editor) {
const { from, to } = editor.state.selection;
if (to > from) {
const sel = editor.state.doc.textBetween(from, to, '\n');
if (sel && !sel.includes('\n')) findText = sel;
}
}
applySearch();
});
// Each Ctrl+F / Ctrl+H bumps focusNonce → refocus and reveal the replace row as asked.
$effect(() => {
void focusNonce;
showReplace = showReplace || mode === 'replace';
queueMicrotask(() => { findInput?.focus(); findInput?.select(); });
});
</script>
<div class="find-bar" role="dialog" aria-label="Find and replace">
<button class="fb-expand" onclick={() => (showReplace = !showReplace)} title={showReplace ? 'Hide replace' : 'Show replace'} aria-label="Toggle replace">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" style="transform: rotate({showReplace ? 90 : 0}deg)">
<path d="M3 1.5l3.5 3.5L3 8.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="fb-rows">
<div class="fb-row">
<input
bind:this={findInput}
bind:value={findText}
oninput={applySearch}
onkeydown={onFindKeydown}
type="text"
placeholder="Find"
spellcheck="false"
autocomplete="off"
/>
<span class="fb-count">
{#if findText && results.count === 0}No results
{:else if results.count > 0}{results.current + 1}/{results.count}
{/if}
</span>
<button class="fb-opt" class:on={matchCase} onclick={toggleCase} title="Match case" aria-pressed={matchCase}>Aa</button>
<button class="fb-opt fb-word" class:on={wholeWord} onclick={toggleWord} title="Whole word" aria-pressed={wholeWord}>
<span>W</span>
</button>
<button class="fb-nav" onclick={prev} disabled={!results.count} title="Previous (Shift+Enter)" aria-label="Previous match">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 7.5L6 4l3.5 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="fb-nav" onclick={next} disabled={!results.count} title="Next (Enter)" aria-label="Next match">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 4.5L6 8l3.5-3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="fb-close" onclick={onClose} title="Close (Esc)" aria-label="Close">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
{#if showReplace}
<div class="fb-row">
<input
bind:value={replaceText}
onkeydown={onReplaceKeydown}
type="text"
placeholder="Replace"
spellcheck="false"
autocomplete="off"
/>
<button class="fb-text-btn" onclick={replaceOne} disabled={!results.count} title="Replace current match">Replace</button>
<button class="fb-text-btn" onclick={replaceEvery} disabled={!results.count} title="Replace all matches">All</button>
</div>
{/if}
</div>
</div>
<style>
.find-bar {
display: flex;
align-items: flex-start;
gap: 0.25rem;
padding: 0.4rem 0.5rem;
background: var(--color-toolbar-bg, #fff);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
font-family: var(--font-sans);
}
.fb-rows { display: flex; flex-direction: column; gap: 0.3rem; }
.fb-row { display: flex; align-items: center; gap: 0.25rem; }
input {
width: 14rem;
box-sizing: border-box;
padding: 0.3rem 0.45rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
/* --color-surface (not page-bg): page-bg stays white in dark mode, surface follows the theme. */
background: var(--color-surface);
color: var(--color-text);
font-size: 0.82rem;
}
.fb-count {
min-width: 4.2rem;
text-align: center;
font-size: 0.72rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.7rem;
height: 1.7rem;
padding: 0 0.4rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-size: 0.74rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
button:hover:not(:disabled) { background: var(--color-btn-hover, rgba(0, 0, 0, 0.06)); }
button:disabled { opacity: 0.4; cursor: default; }
.fb-opt.on { background: var(--color-primary); color: #fff; }
.fb-opt { font-weight: 600; }
.fb-word span { text-decoration: underline; }
.fb-text-btn { min-width: auto; padding: 0 0.6rem; border-color: var(--color-border); }
.fb-expand { min-width: 1.2rem; width: 1.2rem; align-self: center; }
</style>
+2 -1
View File
@@ -84,7 +84,8 @@
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-page-bg, #fff);
/* --color-surface (not page-bg): page-bg stays white in dark mode, surface follows the theme. */
background: var(--color-surface);
color: var(--color-text);
font-size: 0.85rem;
}
+2
View File
@@ -29,6 +29,7 @@ import { PageBreak } from './extensions/pageBreak';
import { Indent } from './extensions/indent';
import { FormattingMarks } from './extensions/formattingMarks';
import { SpellCheck } from './extensions/spellCheck';
import { SearchReplace } from './extensions/searchReplace';
import { TableView } from './extensions/tableView';
import { TableColumnResize } from './extensions/tableColumnResize';
import { TableRowResize } from './extensions/tableRowResize';
@@ -91,4 +92,5 @@ export const extensions = [
PageBreaks,
FormattingMarks,
SpellCheck,
SearchReplace,
];
+210
View File
@@ -0,0 +1,210 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { Node as PmNode } from '@tiptap/pm/model';
// Find & Replace: highlights all matches via decorations, navigates/replaces via commands.
// Pure editor feature — never touches the document model beyond the actual replacements.
export interface SearchOptions { term: string; matchCase: boolean; wholeWord: boolean; }
interface Match { from: number; to: number; }
interface SearchState extends SearchOptions {
matches: Match[];
current: number; // index into matches, or -1
deco: DecorationSet;
}
export const searchKey = new PluginKey<SearchState>('searchReplace');
const SET_SEARCH = 'searchReplace/setSearch';
const SET_CURRENT = 'searchReplace/setCurrent';
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function buildSearchRegex(term: string, matchCase: boolean, wholeWord: boolean): RegExp | null {
if (!term) return null;
let pat = escapeRegExp(term);
if (wholeWord) pat = `\\b${pat}\\b`;
try { return new RegExp(pat, matchCase ? 'g' : 'gi'); }
catch { return null; }
}
// All [start, end) char ranges of a global regex in `text` (skips zero-width matches).
export function regexRanges(text: string, re: RegExp): [number, number][] {
const out: [number, number][] = [];
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m[0].length === 0) { re.lastIndex++; continue; }
out.push([m.index, m.index + m[0].length]);
}
return out;
}
// Matches within each textblock, mapped to doc positions. Matches span runs/marks but
// never block boundaries (each textblock is searched on its own concatenated text).
function findMatches(doc: PmNode, re: RegExp | null): Match[] {
if (!re) return [];
const matches: Match[] = [];
doc.descendants((node, pos) => {
if (!node.isTextblock) return true;
let text = '';
const map: number[] = []; // map[i] = doc position of char i
node.forEach((child, offset) => {
if (child.isText) {
const t = child.text ?? '';
for (let k = 0; k < t.length; k++) { text += t[k]; map.push(pos + 1 + offset + k); }
} else {
text += '￿'; // inline atom (image/hardBreak): one non-matching slot
map.push(pos + 1 + offset);
}
});
for (const [s, e] of regexRanges(text, re)) matches.push({ from: map[s], to: map[e - 1] + 1 });
return false;
});
return matches;
}
function buildDeco(doc: PmNode, matches: Match[], current: number): DecorationSet {
if (!matches.length) return DecorationSet.empty;
return DecorationSet.create(doc, matches.map((m, i) =>
Decoration.inline(m.from, m.to, { class: i === current ? 'search-match search-match-current' : 'search-match' }),
));
}
// First match at/after the cursor (wraps to the first match), or -1 when none.
function pickCurrent(matches: Match[], selFrom: number): number {
if (!matches.length) return -1;
const i = matches.findIndex(m => m.from >= selFrom);
return i === -1 ? 0 : i;
}
const EMPTY: SearchState = { term: '', matchCase: false, wholeWord: false, matches: [], current: -1, deco: DecorationSet.empty };
// Match count + current index for the toolbar UI (FindReplaceBar.svelte).
export function getSearchState(state: EditorState): { count: number; current: number; term: string } {
const s = searchKey.getState(state);
return { count: s?.matches.length ?? 0, current: s?.current ?? -1, term: s?.term ?? '' };
}
function moveCurrent(state: EditorState, dispatch: ((tr: Transaction) => void) | undefined, dir: 1 | -1): boolean {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const n = s.matches.length;
const idx = s.current < 0 ? (dir > 0 ? 0 : n - 1) : (s.current + dir + n) % n;
const m = s.matches[idx];
if (dispatch) {
const tr = state.tr.setMeta(SET_CURRENT, idx);
tr.setSelection(TextSelection.create(tr.doc, m.from, m.to));
tr.scrollIntoView();
dispatch(tr);
}
return true;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
searchReplace: {
setSearch: (opts: SearchOptions) => ReturnType;
findNext: () => ReturnType;
findPrevious: () => ReturnType;
scrollToCurrent: () => ReturnType;
replaceCurrent: (text: string) => ReturnType;
replaceAll: (text: string) => ReturnType;
clearSearch: () => ReturnType;
};
}
}
export const SearchReplace = Extension.create({
name: 'searchReplace',
addProseMirrorPlugins() {
return [
new Plugin<SearchState>({
key: searchKey,
state: {
init: () => EMPTY,
apply(tr: Transaction, old: SearchState, _oldState: EditorState, newState: EditorState): SearchState {
const set = tr.getMeta(SET_SEARCH) as SearchOptions | undefined;
if (set) {
const matches = findMatches(newState.doc, buildSearchRegex(set.term, set.matchCase, set.wholeWord));
const current = pickCurrent(matches, newState.selection.from);
return { ...set, matches, current, deco: buildDeco(newState.doc, matches, current) };
}
const cur = tr.getMeta(SET_CURRENT) as number | undefined;
if (cur !== undefined) return { ...old, current: cur, deco: buildDeco(newState.doc, old.matches, cur) };
if (tr.docChanged && old.term) {
const matches = findMatches(newState.doc, buildSearchRegex(old.term, old.matchCase, old.wholeWord));
const current = pickCurrent(matches, newState.selection.from);
return { ...old, matches, current, deco: buildDeco(newState.doc, matches, current) };
}
return old;
},
},
props: {
decorations(state) { return searchKey.getState(state)?.deco; },
},
}),
];
},
addCommands() {
return {
setSearch: (opts: SearchOptions) => ({ state, dispatch }) => {
if (dispatch) dispatch(state.tr.setMeta(SET_SEARCH, opts));
return true;
},
findNext: () => ({ state, dispatch }) => moveCurrent(state, dispatch, 1),
findPrevious: () => ({ state, dispatch }) => moveCurrent(state, dispatch, -1),
scrollToCurrent: () => ({ state, dispatch }) => {
const s = searchKey.getState(state);
if (!s || s.current < 0) return false;
const m = s.matches[s.current];
if (dispatch) dispatch(state.tr.setSelection(TextSelection.create(state.doc, m.from, m.to)).scrollIntoView());
return true;
},
replaceCurrent: (text: string) => ({ state, dispatch }) => {
const s = searchKey.getState(state);
if (!s || s.current < 0 || !s.matches.length) return false;
const m = s.matches[s.current];
if (dispatch) {
// Marks of the first matched char (resolving at m.from would read the
// boundary before the match, i.e. the preceding run's formatting).
const marks = state.doc.resolve(m.from + 1).marks();
const tr = state.tr;
if (text) tr.replaceWith(m.from, m.to, state.schema.text(text, marks));
else tr.delete(m.from, m.to);
// Land the cursor after the replacement so the recompute picks the next match.
tr.setSelection(TextSelection.create(tr.doc, m.from + text.length));
tr.scrollIntoView();
dispatch(tr);
}
return true;
},
replaceAll: (text: string) => ({ state, dispatch }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
if (dispatch) {
const tr = state.tr;
// Reverse order so earlier positions stay valid as we splice.
for (let i = s.matches.length - 1; i >= 0; i--) {
const m = s.matches[i];
const marks = state.doc.resolve(m.from + 1).marks();
if (text) tr.replaceWith(m.from, m.to, state.schema.text(text, marks));
else tr.delete(m.from, m.to);
}
dispatch(tr);
}
return true;
},
clearSearch: () => ({ state, dispatch }) => {
const s = searchKey.getState(state);
if (dispatch) dispatch(state.tr.setMeta(SET_SEARCH, { term: '', matchCase: s?.matchCase ?? false, wholeWord: s?.wholeWord ?? false }));
return true;
},
};
},
});
+9
View File
@@ -154,6 +154,15 @@
cursor: text;
}
/* Find & Replace match highlights (searchReplace.ts); current match is stronger. */
.paper .tiptap .search-match {
background: rgba(255, 213, 0, 0.45);
border-radius: 2px;
}
.paper .tiptap .search-match-current {
background: rgba(255, 145, 0, 0.8);
}
/* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding
box; the rotor holds the <img>, resize handles and rotate grip, rotating together. */
.paper .tiptap .image-node {
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { buildSearchRegex, regexRanges } from '../../src/lib/editor/extensions/searchReplace';
function ranges(text: string, term: string, matchCase = false, wholeWord = false): [number, number][] {
const re = buildSearchRegex(term, matchCase, wholeWord);
return re ? regexRanges(text, re) : [];
}
describe('buildSearchRegex', () => {
it('returns null for an empty term', () => {
expect(buildSearchRegex('', false, false)).toBeNull();
});
it('escapes regex metacharacters (literal match)', () => {
expect(ranges('a.b a_b', 'a.b')).toEqual([[0, 3]]); // the dot is literal, not "any char"
});
it('is case-insensitive by default, case-sensitive when asked', () => {
expect(ranges('Foo foo FOO', 'foo')).toEqual([[0, 3], [4, 7], [8, 11]]);
expect(ranges('Foo foo FOO', 'foo', true)).toEqual([[4, 7]]);
});
it('honours whole-word boundaries', () => {
expect(ranges('cat category scat', 'cat')).toEqual([[0, 3], [4, 7], [14, 17]]);
expect(ranges('cat category scat', 'cat', false, true)).toEqual([[0, 3]]);
});
});
describe('regexRanges', () => {
it('finds all non-overlapping matches', () => {
expect(ranges('abab', 'ab')).toEqual([[0, 2], [2, 4]]);
});
it('returns nothing when there is no match', () => {
expect(ranges('hello', 'xyz')).toEqual([]);
});
it('does not loop on zero-width patterns', () => {
// term is escaped so this can't actually be zero-width, but guard the helper directly
const re = new RegExp('', 'g');
expect(regexRanges('abc', re)).toEqual([]);
});
});