From 9892377ee342e9175d156d50bcf443cb5b556c14 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 11:19:02 +0200 Subject: [PATCH] feat: Find & Replace also searches font, size and colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three attrs LibreOffice's Format… dialog is mostly about, on both sides of the bar. They live together on one TextStyle mark and a mark of one type replaces that type whole, so each run's own attrs are the base of the new one — recolouring leaves the font alone. F3 was bound twice from here on: it now expands an AutoText shortcut where the caret has one, as it does in both word processors, and finds the next match otherwise. --- CHANGELOG.md | 3 +- src/App.svelte | 4 ++ src/lib/components/FindReplaceBar.svelte | 38 ++++++++++++ src/lib/editor/CLAUDE.md | 1 + src/lib/editor/extensions/CLAUDE.md | 4 +- src/lib/editor/extensions/searchReplace.ts | 68 ++++++++++++++++++++-- src/lib/i18n/locales/de.ts | 4 ++ src/lib/i18n/locales/en.ts | 4 ++ tests/unit/search-format.test.ts | 39 +++++++++++++ 9 files changed, 157 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b28497b..bd0d836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,7 +114,7 @@ - A paragraph of its own can opt out of the document's automatic hyphenation (Format ▸ Paragraph ▸ Text Flow, as LibreOffice has it). Only "off" travels — Word cannot turn hyphenation *on* for one paragraph — and only under a document that hyphenates: below that switch it says what is already true. Round-trips as ODF `fo:hyphenate="false"` in the paragraph's **text** properties and Word's `w:suppressAutoHyphens` - 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) - A `¶` toggle searches and replaces **formatting**, as LibreOffice's Format… and its Paragraph Styles box do: bold / italic / underline and a paragraph style, on the search side and on the replacement side. A format narrows a text search to the runs carrying it, and with the search field empty the formatting is the search — every such run, or every paragraph in that style. A replacement with no text only reformats, so a whole document can be restyled without retyping it, and a heading style switches the block over as the style gallery does + A `¶` toggle searches and replaces **formatting**, as LibreOffice's Format… and its Paragraph Styles box do: bold / italic / underline, font, size and colour, plus a paragraph style — on the search side and on the replacement side. A format narrows a text search to the runs carrying it, and with the search field empty the formatting is the search — every such run, or every paragraph in that style. A replacement with no text only reformats, so a whole document can be restyled without retyping it, and a heading style switches the block over as the style gallery does - 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) @@ -163,7 +163,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. - A formula's own currency and date formats (the number and percentage ones are implemented), and a formula reaching into another table — LibreOffice's `` has no counterpart in Word's field language, so it could not survive the DOCX leg - 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 - Linked / chained text frames -- Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog - Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare - Thesaurus and grammar check - A vertical writing mode for the **page** (ODF `tb-rl` on the page layout): a text box can run its text top-to-bottom, the body cannot — pagination fills a page downwards. A ruby annotation's own alignment and position are not offered either; both products' defaults are what we write diff --git a/src/App.svelte b/src/App.svelte index 785139d..b61f2c0 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -926,6 +926,10 @@ ]; function onKeydown(e: KeyboardEvent) { + // The editor gets the key first: F3 expands an AutoText shortcut where the caret + // has one (both word processors' key for it) and only otherwise finds the next + // match, which is what the extension leaves unhandled. + if (e.defaultPrevented) return; for (const [combo, run] of appActions) { if (!matchesEvent(e, combo)) continue; e.preventDefault(); diff --git a/src/lib/components/FindReplaceBar.svelte b/src/lib/components/FindReplaceBar.svelte index 30a543a..0dc5bc7 100644 --- a/src/lib/components/FindReplaceBar.svelte +++ b/src/lib/components/FindReplaceBar.svelte @@ -162,6 +162,35 @@ {#each paraStyles as s}{/each} + setFormat(find, { font: e.currentTarget.value.trim() || undefined })} + /> + setFormat(find, { sizePt: Number(e.currentTarget.value) || undefined })} + /> + setFormat(find, { color: e.currentTarget.value })} + /> + {#if spec(find).color} + + {/if} {/each} {/if} @@ -227,6 +256,15 @@ .fb-fmt-italic { font-style: italic; } .fb-fmt-underline { text-decoration: underline; } + .fb-font { width: 6rem; } + .fb-size { width: 3.4rem; } + .fb-color { + width: 1.9rem; + height: 1.7rem; + padding: 1px; + cursor: pointer; + } + .fb-fmt-label { min-width: 4.6rem; font-size: 0.72rem; diff --git a/src/lib/editor/CLAUDE.md b/src/lib/editor/CLAUDE.md index 421e868..ab5a904 100644 --- a/src/lib/editor/CLAUDE.md +++ b/src/lib/editor/CLAUDE.md @@ -25,6 +25,7 @@ the tooltips (via `withShortcut`, which localizes Ctrl/Shift/Alt and swaps in `event.key` is layout-dependent (German AltGr+2 = `²`) and prosemirror-keymap skips its keyCode fallback for exactly that modifier pair. Those seven run in `addProseMirrorPlugins` off `event.code` (`Digit0`–`Digit6`) instead. +- **F3 is shared**: AutoText expands a shortcut at the caret (both word processors' key), and the app's find-next runs only where it did not — the window handler bails on `defaultPrevented`, which the editor's keymap sets when a binding took the key. - Deliberate resolutions: `Mod-m` stays LibreOffice's *clear formatting* (Word's increase-indent is Tab); `Ctrl+1/2/5` are Word's **line spacing**, so headings are Ctrl+Alt+N only; `Mod-Shift-s` stays strikethrough, so there's no Save-As key; diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index ac8fb25..9401a2a 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -34,7 +34,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph is **boxed to the frame it captions** (`captionPlacement`), so it stands under the picture instead of at the margin: LibreOffice frames picture and caption together, and a loose paragraph carries that over as `indent`/`indentRight` — the frame's own span in the column, measured (`frameSpanCm`), since that span comes from the wrap, the offset and the column, not from one attr. The indent is also what lifts the caption clear of a **side-wrapped** frame: a line with no room beside a float drops below it. It is centred in that box only under a frame that is itself centred; a block with no frame at all has only its own alignment to pass on. `getFrameDebug` reports the same two spans for the debug dump. `CaptionDialog.svelte` (References tab) owns the insert. - **`ruby.ts`** — a reading printed over its base (LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying both halves, because that is what both formats write: one element holding a base and a reading, edited as a unit (`RubyDialog.svelte`). Rendered as a real ``, so the browser sets it. Alignment and position are both products' defaults and are not offered. - **`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. `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. A `FormatSpec` (weight/slant/underline and a paragraph style, LibreOffice's Format… and its Paragraph Styles box) narrows a text search to the runs carrying it, and **with no term is the search**: every such run, or every paragraph in the style, empty ones included. The same spec on the replacement applies it — and a replacement with **no text** keeps the text and only reformats, which is how both dialogs restyle a document without retyping it. A style also switches the node type, as `setParagraphStyle` does, so the extension takes the same `sheet` option. +- **`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. A `FormatSpec` (weight/slant/underline, font, size and colour — LibreOffice's Format… — plus a paragraph style, its Paragraph Styles box) narrows a text search to the runs carrying it, and **with no term is the search**: every such run, or every paragraph in the style, empty ones included. The same spec on the replacement applies it — and a replacement with **no text** keeps the text and only reformats, which is how both dialogs restyle a document without retyping it. A style also switches the node type, as `setParagraphStyle` does, so the extension takes the same `sheet` option. Font, size and colour live together on one **TextStyle** mark, and a mark of one type replaces that type whole, so each run's own attrs are the base of the new one — replacing the colour leaves the font alone. Sizes compare by value (12pt and 12.0pt are one size), colours and family names case-insensitively. - **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu. - **`outline.ts`** — the document's chapters for `NavigatorPane.svelte`: every **top-level** heading owning the blocks up to the next heading of its level or above (one inside a cell or a text box is not a chapter — moving it would move it out of its container). The two operations are pure functions the commands only apply: `chapterMove` returns the range to lift and where to put it back — the insert position for a move down is already corrected for the delete — and `chapterLevels` the headings a promote/demote rewrites, null when any of the subtree would leave 1…`maxLevel`. A move swaps with the adjacent chapter at the **same** level and stops at a heading above it, so a subheading never reparents itself; the level shift rewrites `styleName` too, or the paragraph would keep the old heading's look. - **`indexEntry.ts`** — one place marked for the alphabetical index: an inline **atom**, not a mark, because that is what both formats write (a point `text:alphabetical-index-mark`, a Word `XE` field). It carries the term, so the marked text and the word the index lists can differ, plus `key1` for the heading it files under. `indexRows` merges the marks into the index's rows — one per term, its pages de-duplicated and sorted, the terms sorted case-insensitively by locale. @@ -45,7 +45,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as ``; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one). - **`autoCorrect.ts`** — AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options): typographic quotes per document language, its dash matrix, its replacement table, sentence capitalization and TWo INitials. One `handleTextInput` plugin, listed **last** in `extensions.ts` so every input rule (list markers, the rule line) still fires first; `autoCorrectFix(before, opts, lang)` is pure — the whole table is `tests/unit/autocorrect.test.ts`. URL recognition and auto-list are TipTap's own (`Link autolink` — gated by the same options — and the list input rules). - **`wordCompletion.ts`** — LibreOffice's Word Completion (its AutoCorrect dialog's own tab): every word of at least `minLength` letters is collected as it is finished, and typing its first three letters offers the rest. Drawn as a **widget decoration** after the caret, not LibreOffice's tooltip and not its selected-text insert — the offer is outside the document until Enter takes it, so nothing has to be undone when it is not. Only while **typing**: the offer is dropped on a bare caret move, or an Enter meant to split the paragraph would complete a word instead. `priority: 1000`, so its `handleKeyDown` sees Enter ahead of the keymaps. The list and its settings live in `storage/wordCompletion.svelte.ts`, the collection and matching rules in `utils/wordCompletion.ts`. -- **`autoText.ts`** — LibreOffice's AutoText / Word's Quick Parts: a named block of text, inserted from the library (`AutoTextDialog.svelte`) or by typing its shortcut and pressing **F3**, which is LibreOffice's key for it. An entry is stored as HTML and parsed back through the schema, so it keeps its marks and blocks and survives a schema that has grown. The library belongs to the app, not to a document (`storage/autoText.svelte.ts`) — as it does in both products, which keep it in a template of their own; nothing about it reaches a saved file. +- **`autoText.ts`** — LibreOffice's AutoText / Word's Quick Parts: a named block of text, inserted from the library (`AutoTextDialog.svelte`) or by typing its shortcut and pressing **F3**, which is both products' key for it — and which the app also binds to find-next: the editor sees the key first, so it expands a shortcut where the caret has one and leaves the key alone otherwise (`App.svelte`'s window handler returns on `defaultPrevented`). An entry is stored as HTML and parsed back through the schema, so it keeps its marks and blocks and survives a schema that has grown. The library belongs to the app, not to a document (`storage/autoText.svelte.ts`) — as it does in both products, which keep it in a template of their own; nothing about it reaches a saved file. - **`shortcuts.ts`** — see `src/lib/editor/CLAUDE.md`. `extensions.ts` deliberately keeps TipTap's `resizable: false` (its columnResizing plugin stays diff --git a/src/lib/editor/extensions/searchReplace.ts b/src/lib/editor/extensions/searchReplace.ts index 172c241..e2110ce 100644 --- a/src/lib/editor/extensions/searchReplace.ts +++ b/src/lib/editor/extensions/searchReplace.ts @@ -12,13 +12,56 @@ import type { StyleSheet } from '../../styles/styleSheet'; // What a search asks of a match beyond its text, and what a replacement applies to it — // both dialogs search and replace formatting on its own (LibreOffice's Format… and its // Paragraph Styles box). With no search term the formatting *is* the search. -export type FormatSpec = { bold?: boolean; italic?: boolean; underline?: boolean; style?: string }; +export type FormatSpec = { + bold?: boolean; + italic?: boolean; + underline?: boolean; + style?: string; + /** The three TextStyle attrs, the rest of what LibreOffice's Format… dialog sets. */ + font?: string; + sizePt?: number; + color?: string; +}; export const FORMAT_MARKS = ['bold', 'italic', 'underline'] as const; const markNames = (f?: FormatSpec): string[] => FORMAT_MARKS.filter((m) => f?.[m]); -export const hasFormat = (f?: FormatSpec): boolean => !!f && (!!f.style || markNames(f).length > 0); +// The TextStyle attrs a spec asks for, in the attr names the mark carries them under. +function textStyleAttrs(f?: FormatSpec): Record { + const out: Record = {}; + if (f?.font) out.fontFamily = f.font; + if (f?.sizePt) out.fontSize = `${f.sizePt}pt`; + if (f?.color) out.color = f.color; + return out; +} + +export const hasFormat = (f?: FormatSpec): boolean => + !!f && (!!f.style || markNames(f).length > 0 || Object.keys(textStyleAttrs(f)).length > 0); + +const ptOf = (v: unknown): number | null => { + const m = /^(-?[\d.]+)\s*(pt|px)?$/.exec(String(v ?? '').trim()); + if (!m) return null; + const n = parseFloat(m[1]); + return Number.isFinite(n) ? (m[2] === 'px' ? (n * 72) / 96 : n) : null; +}; + +const sameColor = (a: unknown, b: string): boolean => + String(a ?? '').trim().toLowerCase() === b.trim().toLowerCase(); + +// Whether one run carries everything the spec asks of it: the marks, and the TextStyle +// attrs compared by value rather than by string (12pt and 12.0pt are one size). +function runMatches(marks: readonly { type: { name: string }; attrs: Record }[], f: FormatSpec, wanted: string[]): boolean { + if (!wanted.every((m) => marks.some((k) => k.type.name === m))) return false; + const ts = marks.find((m) => m.type.name === 'textStyle')?.attrs ?? {}; + if (f.font && String(ts.fontFamily ?? '').toLowerCase() !== f.font.toLowerCase()) return false; + if (f.sizePt) { + const pt = ptOf(ts.fontSize); + if (pt == null || Math.abs(pt - f.sizePt) > 0.01) return false; + } + if (f.color && !sameColor(ts.color, f.color)) return false; + return true; +} export interface SearchOptions { term: string; @@ -87,7 +130,7 @@ function findMatches(doc: PmNode, re: RegExp | null, format?: FormatSpec): Match const map: number[] = []; // map[i] = doc position of char i const ok: boolean[] = []; // ok[i] = char i carries every mark the search asks for node.forEach((child, offset) => { - const marked = wanted.every((m) => child.marks.some((k) => k.type.name === m)); + const marked = !format || runMatches(child.marks, format, wanted); if (child.isText) { const t = child.text ?? ''; for (let k = 0; k < t.length; k++) { text += t[k]; map.push(pos + 1 + offset + k); ok.push(marked); } @@ -102,7 +145,7 @@ function findMatches(doc: PmNode, re: RegExp | null, format?: FormatSpec): Match if (ok.slice(s, e).some((v) => !v)) continue; matches.push({ from: map[s], to: map[e - 1] + 1, groups }); } - } else if (wanted.length) { + } else if (wanted.length || Object.keys(textStyleAttrs(format)).length) { for (let i = 0; i < ok.length; i++) { if (!ok[i]) continue; let j = i; @@ -125,6 +168,23 @@ function applyFormat(tr: Transaction, schema: Schema, from: number, to: number, const type = schema.marks[name]; if (type) tr.addMark(from, to, type.create()); } + // TextStyle carries font, size and colour together, and a mark of one type replaces + // the mark of that type whole — so each run's own attrs are the base of the new one. + const patch = textStyleAttrs(fmt); + const ts = schema.marks.textStyle; + if (ts && Object.keys(patch).length) { + const runs: { from: number; to: number; attrs: Record }[] = []; + tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText) return true; + runs.push({ + from: Math.max(pos, from), + to: Math.min(pos + node.nodeSize, to), + attrs: { ...(node.marks.find((m) => m.type === ts)?.attrs ?? {}), ...patch }, + }); + return false; + }); + for (const run of runs) if (run.to > run.from) tr.addMark(run.from, run.to, ts.create(run.attrs)); + } if (!fmt.style) return; // A heading style also switches the node type, as setParagraphStyle does; anything // else turns a heading back into a paragraph. diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 198c3e3..c77c15d 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -509,6 +509,10 @@ const de: Messages = { format: 'Formatierung suchen und ersetzen', formatFind: 'Suchen:', formatReplace: 'Ersetzen:', + font: 'Schrift', + sizePt: 'pt', + color: 'Farbe', + noColor: 'Beliebige Farbe', paragraphStyle: 'Absatzvorlage', anyStyle: 'Beliebige Vorlage', previous: 'Zurück', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 3ed2a0b..0dc595c 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -513,6 +513,10 @@ const en = { format: 'Search and replace formatting', formatFind: 'Find:', formatReplace: 'Replace:', + font: 'Font', + sizePt: 'pt', + color: 'Color', + noColor: 'Any color', paragraphStyle: 'Paragraph style', anyStyle: 'Any style', previous: 'Previous', diff --git a/tests/unit/search-format.test.ts b/tests/unit/search-format.test.ts index 00b9817..e65e57c 100644 --- a/tests/unit/search-format.test.ts +++ b/tests/unit/search-format.test.ts @@ -10,6 +10,8 @@ import Heading from '@tiptap/extension-heading'; import Bold from '@tiptap/extension-bold'; import Italic from '@tiptap/extension-italic'; import Underline from '@tiptap/extension-underline'; +import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style'; +import { FontColor } from '../../src/lib/editor/extensions/fontColor'; import { SearchReplace, getSearchState } from '../../src/lib/editor/extensions/searchReplace'; import { ParagraphStyle } from '../../src/lib/editor/extensions/paragraphStyle'; import { builtinStyleSheet } from '../../src/lib/styles/styleSheet'; @@ -28,6 +30,7 @@ function makeEditor(content: N) { element: el, extensions: [ Document, Paragraph, Text, Heading, Bold, Italic, Underline, + TextStyle, FontFamily, FontSize, FontColor, ParagraphStyle.configure({ sheet }), SearchReplace.configure({ sheet }), ], content, @@ -75,6 +78,42 @@ describe('search by formatting', () => { }); }); +describe('search by font, size and colour', () => { + const styled = (text: string, attrs: N): N => + ({ type: 'text', text, marks: [{ type: 'textStyle', attrs }] }); + const mixed: N = { + type: 'doc', + content: [P(null, + styled('one', { fontSize: '18pt' }), + { type: 'text', text: ' two ' }, + styled('three', { color: '#FF0000', fontFamily: 'Arial' }), + )], + }; + + it('finds the runs a size, a font or a colour names', () => { + const e = makeEditor(mixed); + const find = (format: N) => + (e.commands.setSearch({ term: '', matchCase: false, wholeWord: false, useRegex: false, format }), + getSearchState(e.state).count); + expect(find({ sizePt: 18 })).toBe(1); + expect(find({ sizePt: 12 })).toBe(0); + expect(find({ color: '#ff0000' })).toBe(1); + expect(find({ font: 'arial' })).toBe(1); + expect(find({ font: 'Arial', color: '#0000FF' })).toBe(0); + e.destroy(); + }); + + it('applies a colour and leaves the run\'s font alone', () => { + const e = makeEditor(mixed); + e.commands.setSearch({ term: 'three', matchCase: false, wholeWord: false, useRegex: false }); + e.commands.replaceAll('', { color: '#0000FF' }); + const attrs = e.state.doc.firstChild!.lastChild!.marks.find((m: N) => m.type.name === 'textStyle')?.attrs; + expect(attrs.color).toBe('#0000FF'); + expect(attrs.fontFamily).toBe('Arial'); + e.destroy(); + }); +}); + describe('replace by formatting', () => { it('keeps the text and only applies the format when the replacement is empty', () => { const e = makeEditor(doc);