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:
1 parent
46fb30249f
commit
40efc73f86
7 files changed
+67
-23
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('!');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user