diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fbc26c..dbb4088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/components/FindReplaceBar.svelte b/src/lib/components/FindReplaceBar.svelte index 2eacdd5..38a8f51 100644 --- a/src/lib/components/FindReplaceBar.svelte +++ b/src/lib/components/FindReplaceBar.svelte @@ -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(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 @@ + diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index b64015e..8646101 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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). diff --git a/src/lib/editor/extensions/searchReplace.ts b/src/lib/editor/extensions/searchReplace.ts index 18f7e88..180f760 100644 --- a/src/lib/editor/extensions/searchReplace.ts +++ b/src/lib/editor/extensions/searchReplace.ts @@ -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; }, }; diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index cc2dd9c..e79df32 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index e995aed..144203b 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/tests/unit/search-replace.test.ts b/tests/unit/search-replace.test.ts index f9c981e..8562446 100644 --- a/tests/unit/search-replace.test.ts +++ b/tests/unit/search-replace.test.ts @@ -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(''); + expect(expandGroups('$3!', ['a@b', 'a', 'b'])).toBe('!'); + }); +});