feat: Find & Replace also searches font, size and colour
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.
This commit is contained in:
1 parent
c800618299
commit
9892377ee3
9 files changed
+157
-8
No files matched your search
+1
-2
@@ -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 `<Table1.A1>` 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -162,6 +162,35 @@
|
||||
<option value="">{t().findReplace.anyStyle}</option>
|
||||
{#each paraStyles as s}<option value={s.name}>{s.name}</option>{/each}
|
||||
</select>
|
||||
<input
|
||||
class="fb-font"
|
||||
type="text"
|
||||
value={spec(find).font ?? ''}
|
||||
placeholder={t().findReplace.font}
|
||||
aria-label={t().findReplace.font}
|
||||
spellcheck="false"
|
||||
onchange={(e) => setFormat(find, { font: e.currentTarget.value.trim() || undefined })}
|
||||
/>
|
||||
<input
|
||||
class="fb-size"
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
value={spec(find).sizePt ?? ''}
|
||||
placeholder={t().findReplace.sizePt}
|
||||
aria-label={t().findReplace.sizePt}
|
||||
onchange={(e) => setFormat(find, { sizePt: Number(e.currentTarget.value) || undefined })}
|
||||
/>
|
||||
<input
|
||||
class="fb-color"
|
||||
type="color"
|
||||
value={spec(find).color ?? '#000000'}
|
||||
aria-label={t().findReplace.color}
|
||||
onchange={(e) => setFormat(find, { color: e.currentTarget.value })}
|
||||
/>
|
||||
{#if spec(find).color}
|
||||
<button class="fb-opt" title={t().findReplace.noColor} onclick={() => setFormat(find, { color: undefined })}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `<ruby>`, 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 `<span data-page-field>`; `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
|
||||
|
||||
@@ -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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, unknown> }[], 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<string, unknown> }[] = [];
|
||||
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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user