feat: Find & Replace by regular expression

A .* toggle beside match-case and whole-word takes the term as a
pattern; a pattern that will not compile simply finds nothing. Each
match keeps its captures, so the replacement expands $1…$9 and $&
the way LibreOffice does — Word has no equivalent.
This commit is contained in:
Steffen Becker committed 2026-08-14 01:04:23 +02:00
1 parent 46fb30249f
commit 40efc73f86
7 files changed
+67 -23

No files matched your search

+2 -1
View File
@@ -93,6 +93,7 @@
- Word/LibreOffice keyboard shortcuts throughout: Ctrl+L/E/R/J alignment, Ctrl+Alt+1–5 headings and Ctrl+Shift+N default style, Ctrl+1/2/5 line spacing, Ctrl+Shift+P / Ctrl+Shift+B super/subscript, Ctrl+Shift+. / Ctrl+Shift+, grow/shrink font, Ctrl+Space clear formatting, Ctrl+Shift+Space non-breaking space, Ctrl+Shift+- soft hyphen, Alt+Shift+D / Alt+Shift+T date & time field, Ctrl+O open, F3 / Shift+F3 find next/previous, Ctrl+F10 formatting marks — alongside the existing Ctrl+B/I/U, Ctrl+M, Ctrl+K, Ctrl+Enter, Ctrl+F/H, Ctrl+S/P and Tab/Shift+Tab. All bindings live in one table, so tooltips stay in sync and they can be remapped later
- Undo / Redo with a labelled history dropdown
- Search & Replace (Ctrl+F / Ctrl+H): live match highlighting, match count, next / previous, match-case and whole-word options, replace current / replace all
A `.*` toggle searches by regular expression, and the replacement expands `$1`…`$9` / `$&` from the match's captures (LibreOffice's syntax; Word has no equivalent)
- Right-click context menu for text: cut / copy / paste / paste without formatting, link insert-edit-remove, clear formatting — with the spelling suggestions merged in on top. Shift+right-click keeps the browser's own menu; images, text boxes and header/footer stay with their existing UI
- Spell check in English and German with squiggles and suggestions in the context menu (add / ignore word); selectable document language. Powered by Hunspell (WASM), so German compound words (Fußgänger, Krankenversicherung …) are recognised; dictionaries are lazy-loaded per language on demand
- Word / character count statistics (whole document and selection)
@@ -138,7 +139,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
- A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it
- Images live as data-URIs in the autosaved JSON, so an image-heavy document can exceed the ~5 MB localStorage quota; `storage/autosave.ts` warns once and a reload restores the last version that still fit. IndexedDB (as `embeddedFontStore.ts` already uses) would lift the ceiling
- Linked / chained text frames
- Find & Replace by regular expression or by formatting/style
- Find & Replace by formatting or by style (the regular-expression half is implemented)
- Save as template (`.ott` / `.dotx`), multi-document management, recent-files list
- Watermark; page background and page border
- Thesaurus, grammar check, word completion, AutoText / building blocks
+4 -1
View File
@@ -12,6 +12,7 @@
let replaceText = $state('');
let matchCase = $state(false);
let wholeWord = $state(false);
let useRegex = $state(false);
let showReplace = $state(false); // set from `mode` in the focusNonce effect below
let findInput = $state<HTMLInputElement | null>(null);
@@ -22,7 +23,7 @@
});
function applySearch() {
editor?.commands.setSearch({ term: findText, matchCase, wholeWord });
editor?.commands.setSearch({ term: findText, matchCase, wholeWord, useRegex });
editor?.commands.scrollToCurrent();
}
@@ -43,6 +44,7 @@
function toggleCase() { matchCase = !matchCase; applySearch(); }
function toggleWord() { wholeWord = !wholeWord; applySearch(); }
function toggleRegex() { useRegex = !useRegex; applySearch(); }
// Prefill from the current selection (single line) on first open.
onMount(() => {
@@ -92,6 +94,7 @@
<button class="fb-opt fb-word" class:on={wholeWord} onclick={toggleWord} title={t().findReplace.wholeWord} aria-pressed={wholeWord}>
<span>W</span>
</button>
<button class="fb-opt" class:on={useRegex} onclick={toggleRegex} title={t().findReplace.regex} aria-pressed={useRegex}>.*</button>
<button class="fb-nav" onclick={prev} disabled={!results.count} title={`${t().findReplace.previous} (${withShortcut('Shift+Enter')})`} aria-label={t().findReplace.previousAria}>
<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>
+1 -1
View File
@@ -29,7 +29,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one.
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements.
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
- **`tableOfContents.ts`** — a generated TOC: block atom listing every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none.
- **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go).
+28 -16
View File
@@ -7,8 +7,10 @@ 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; }
export interface SearchOptions { term: string; matchCase: boolean; wholeWord: boolean; useRegex: boolean; }
// `groups` is the match's captures, so a regex replacement can expand $1…$9 (LibreOffice's
// syntax; Word has no such thing).
interface Match { from: number; to: number; groups: string[] }
interface SearchState extends SearchOptions {
matches: Match[];
current: number; // index into matches, or -1
@@ -23,22 +25,30 @@ function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function buildSearchRegex(term: string, matchCase: boolean, wholeWord: boolean): RegExp | null {
export function buildSearchRegex(term: string, matchCase: boolean, wholeWord: boolean, useRegex = false): RegExp | null {
if (!term) return null;
let pat = escapeRegExp(term);
// A user pattern is wrapped, not escaped — the whole-word guard then bounds it.
let pat = useRegex ? (wholeWord ? `(?:${term})` : term) : 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][] = [];
// $1…$9 and $& in a replacement, filled from one match's captures. Anything the pattern
// did not capture expands to nothing, as it does in LibreOffice.
export function expandGroups(text: string, groups: string[]): string {
return text.replace(/\$([1-9]|&)/g, (_, k: string) => (k === '&' ? groups[0] : groups[Number(k)]) ?? '');
}
// All [start, end) char ranges of a global regex in `text`, with each match's captures
// (skips zero-width matches).
export function regexRanges(text: string, re: RegExp): [number, number, string[]][] {
const out: [number, number, string[]][] = [];
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]);
out.push([m.index, m.index + m[0].length, [...m]]);
}
return out;
}
@@ -61,7 +71,7 @@ function findMatches(doc: PmNode, re: RegExp | null): Match[] {
map.push(pos + 1 + offset);
}
});
for (const [s, e] of regexRanges(text, re)) matches.push({ from: map[s], to: map[e - 1] + 1 });
for (const [s, e, groups] of regexRanges(text, re)) matches.push({ from: map[s], to: map[e - 1] + 1, groups });
return false;
});
return matches;
@@ -81,7 +91,7 @@ function pickCurrent(matches: Match[], selFrom: number): number {
return i === -1 ? 0 : i;
}
const EMPTY: SearchState = { term: '', matchCase: false, wholeWord: false, matches: [], current: -1, deco: DecorationSet.empty };
const EMPTY: SearchState = { term: '', matchCase: false, wholeWord: false, useRegex: 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 } {
@@ -130,14 +140,14 @@ export const SearchReplace = Extension.create({
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 matches = findMatches(newState.doc, buildSearchRegex(set.term, set.matchCase, set.wholeWord, set.useRegex));
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 matches = findMatches(newState.doc, buildSearchRegex(old.term, old.matchCase, old.wholeWord, old.useRegex));
const current = pickCurrent(matches, newState.selection.from);
return { ...old, matches, current, deco: buildDeco(newState.doc, matches, current) };
}
@@ -174,11 +184,12 @@ export const SearchReplace = Extension.create({
// 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 out = s.useRegex ? expandGroups(text, m.groups) : text;
const tr = state.tr;
if (text) tr.replaceWith(m.from, m.to, state.schema.text(text, marks));
if (out) tr.replaceWith(m.from, m.to, state.schema.text(out, 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.setSelection(TextSelection.create(tr.doc, m.from + out.length));
tr.scrollIntoView();
dispatch(tr);
}
@@ -193,7 +204,8 @@ export const SearchReplace = Extension.create({
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));
const out = s.useRegex ? expandGroups(text, m.groups) : text;
if (out) tr.replaceWith(m.from, m.to, state.schema.text(out, marks));
else tr.delete(m.from, m.to);
}
dispatch(tr);
@@ -202,7 +214,7 @@ export const SearchReplace = Extension.create({
},
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 }));
if (dispatch) dispatch(state.tr.setMeta(SET_SEARCH, { term: '', matchCase: s?.matchCase ?? false, wholeWord: s?.wholeWord ?? false, useRegex: s?.useRegex ?? false }));
return true;
},
};
+1
View File
@@ -406,6 +406,7 @@ const de: Messages = {
noResults: 'Keine Treffer',
matchCase: 'Groß-/Kleinschreibung beachten',
wholeWord: 'Ganzes Wort',
regex: 'Regulärer Ausdruck ($1…$9 im Ersetzungstext)',
previous: 'Zurück',
previousAria: 'Vorheriger Treffer',
next: 'Weiter',
+1
View File
@@ -410,6 +410,7 @@ const en = {
noResults: 'No results',
matchCase: 'Match case',
wholeWord: 'Whole word',
regex: 'Regular expression ($1…$9 in the replacement)',
previous: 'Previous',
previousAria: 'Previous match',
next: 'Next',
+30 -4
View File
@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { buildSearchRegex, regexRanges } from '../../src/lib/editor/extensions/searchReplace';
import { buildSearchRegex, expandGroups, 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) : [];
function ranges(text: string, term: string, matchCase = false, wholeWord = false, useRegex = false): [number, number][] {
const re = buildSearchRegex(term, matchCase, wholeWord, useRegex);
return re ? regexRanges(text, re).map(([a, b]) => [a, b] as [number, number]) : [];
}
describe('buildSearchRegex', () => {
@@ -41,3 +41,29 @@ describe('regexRanges', () => {
expect(regexRanges('abc', re)).toEqual([]);
});
});
describe('regular expressions', () => {
it('takes the term as a pattern when asked', () => {
expect(ranges('a.b a_b', 'a.b', false, false, true)).toEqual([[0, 3], [4, 7]]);
expect(ranges('cat 12 dog 345', '\\d+', false, false, true)).toEqual([[4, 6], [11, 14]]);
});
it('still bounds a pattern by whole words', () => {
expect(ranges('cat category', 'ca.', false, true, true)).toEqual([[0, 3]]);
});
it('returns null for a pattern that will not compile', () => {
expect(buildSearchRegex('a(', false, false, true)).toBeNull();
});
it('carries each match\'s captures', () => {
const re = buildSearchRegex('(\\w+)@(\\w+)', false, false, true)!;
expect(regexRanges('mail a@b here', re)).toEqual([[5, 8, ['a@b', 'a', 'b']]]);
});
it('expands $1…$9 and $& in the replacement', () => {
expect(expandGroups('$2, $1', ['a@b', 'a', 'b'])).toBe('b, a');
expect(expandGroups('<$&>', ['a@b', 'a', 'b'])).toBe('<a@b>');
expect(expandGroups('$3!', ['a@b', 'a', 'b'])).toBe('!');
});
});