feat: Find & Replace searches and replaces formatting
The ¶ toggle adds a format to each side: bold / italic / underline and a paragraph style, as LibreOffice's Format… and its Paragraph Styles box. A format narrows a text search to the runs carrying it, and with no term the formatting is the search — every such run, or every paragraph in the style, empty ones included. A replacement with no text only reformats, which is how a document gets restyled without retyping it; a heading style switches the node type over the way setParagraphStyle does, so the extension takes the same sheet.
This commit is contained in:
1 parent
dab1d662c9
commit
2f087bc880
8 files changed
+310
-34
No files matched your search
+2
-1
@@ -109,6 +109,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
|
||||
- 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)
|
||||
@@ -157,7 +158,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
|
||||
- A formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table
|
||||
- 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 formatting or by style (the regular-expression half is implemented)
|
||||
- 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, grammar check, word completion, AutoText / building blocks
|
||||
- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { getSearchState } from '../editor/extensions/searchReplace';
|
||||
import { FORMAT_MARKS, getSearchState, type FormatSpec } from '../editor/extensions/searchReplace';
|
||||
import { styleOrder } from '../styles/styleSheet';
|
||||
import { styleSheet } from '../styles/sheet.svelte';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import { withShortcut } from '../i18n/shortcut';
|
||||
|
||||
@@ -14,8 +16,16 @@
|
||||
let wholeWord = $state(false);
|
||||
let useRegex = $state(false);
|
||||
let showReplace = $state(false); // set from `mode` in the focusNonce effect below
|
||||
let showFormat = $state(false);
|
||||
let findFormat = $state<FormatSpec>({});
|
||||
let replaceFormat = $state<FormatSpec>({});
|
||||
let findInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let paraStyles = $derived(styleOrder(styleSheet()));
|
||||
const markLabel: Record<string, () => string> = {
|
||||
bold: () => t().toolbar.bold, italic: () => t().toolbar.italic, underline: () => t().toolbar.underline,
|
||||
};
|
||||
|
||||
// Live match count / current index, recomputed on every editor transaction.
|
||||
let results = $derived.by(() => {
|
||||
void tick;
|
||||
@@ -23,14 +33,21 @@
|
||||
});
|
||||
|
||||
function applySearch() {
|
||||
editor?.commands.setSearch({ term: findText, matchCase, wholeWord, useRegex });
|
||||
editor?.commands.setSearch({ term: findText, matchCase, wholeWord, useRegex, format: { ...findFormat } });
|
||||
editor?.commands.scrollToCurrent();
|
||||
}
|
||||
|
||||
function next() { editor?.commands.findNext(); }
|
||||
function prev() { editor?.commands.findPrevious(); }
|
||||
function replaceOne() { editor?.commands.replaceCurrent(replaceText); }
|
||||
function replaceEvery() { editor?.commands.replaceAll(replaceText); }
|
||||
function replaceOne() { editor?.commands.replaceCurrent(replaceText, { ...replaceFormat }); }
|
||||
function replaceEvery() { editor?.commands.replaceAll(replaceText, { ...replaceFormat }); }
|
||||
|
||||
function setFormat(find: boolean, patch: FormatSpec) {
|
||||
if (find) { findFormat = { ...findFormat, ...patch }; applySearch(); }
|
||||
else replaceFormat = { ...replaceFormat, ...patch };
|
||||
}
|
||||
|
||||
const spec = (find: boolean): FormatSpec => (find ? findFormat : replaceFormat);
|
||||
|
||||
function onFindKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? prev() : next(); }
|
||||
@@ -95,6 +112,7 @@
|
||||
<span>W</span>
|
||||
</button>
|
||||
<button class="fb-opt" class:on={useRegex} onclick={toggleRegex} title={t().findReplace.regex} aria-pressed={useRegex}>.*</button>
|
||||
<button class="fb-opt" class:on={showFormat} onclick={() => (showFormat = !showFormat)} title={t().findReplace.format} aria-pressed={showFormat}>¶</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>
|
||||
@@ -120,6 +138,33 @@
|
||||
<button class="fb-text-btn" onclick={replaceEvery} disabled={!results.count} title={t().findReplace.replaceAllTitle}>{t().findReplace.all}</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- The formatting a match must carry, and the one a replacement applies. With no
|
||||
search text the formatting is the search, as it is in LibreOffice. -->
|
||||
{#if showFormat}
|
||||
{#each showReplace ? [true, false] : [true] as find}
|
||||
<div class="fb-row">
|
||||
<span class="fb-fmt-label">{find ? t().findReplace.formatFind : t().findReplace.formatReplace}</span>
|
||||
{#each FORMAT_MARKS as mark}
|
||||
<button
|
||||
class="fb-opt fb-fmt-{mark}"
|
||||
class:on={spec(find)[mark]}
|
||||
onclick={() => setFormat(find, { [mark]: spec(find)[mark] ? undefined : true })}
|
||||
title={markLabel[mark]()}
|
||||
aria-pressed={!!spec(find)[mark]}
|
||||
>{markLabel[mark]().slice(0, 1)}</button>
|
||||
{/each}
|
||||
<select
|
||||
value={spec(find).style ?? ''}
|
||||
onchange={(e) => setFormat(find, { style: e.currentTarget.value || undefined })}
|
||||
aria-label={t().findReplace.paragraphStyle}
|
||||
>
|
||||
<option value="">{t().findReplace.anyStyle}</option>
|
||||
{#each paraStyles as s}<option value={s.name}>{s.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +224,25 @@
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
.fb-opt.on { background: var(--color-primary); color: #fff; }
|
||||
.fb-opt { font-weight: 600; }
|
||||
.fb-fmt-italic { font-style: italic; }
|
||||
.fb-fmt-underline { text-decoration: underline; }
|
||||
|
||||
.fb-fmt-label {
|
||||
min-width: 4.6rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
select {
|
||||
height: 1.7rem;
|
||||
max-width: 9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.fb-word span { text-decoration: underline; }
|
||||
.fb-text-btn { min-width: auto; padding: 0 0.6rem; border-color: var(--color-border); }
|
||||
.fb-expand { min-width: 1.2rem; width: 1.2rem; align-self: center; }
|
||||
|
||||
@@ -220,7 +220,7 @@ export const extensions = [
|
||||
Deletion,
|
||||
TrackChanges.configure({ recording: recordChanges, author: () => loadDocProperties().author.trim() }),
|
||||
AutoCorrect,
|
||||
SearchReplace,
|
||||
SearchReplace.configure({ sheet: styleSheet }),
|
||||
// Word/LibreOffice key bindings that aren't TipTap defaults; body:true adds the
|
||||
// ones the header/footer schema has no commands for (headings, line spacing, fields).
|
||||
Shortcuts.configure({ body: true }),
|
||||
|
||||
@@ -33,7 +33,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row.
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **`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.
|
||||
|
||||
@@ -2,12 +2,31 @@ import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state';
|
||||
import type { Node as PmNode } from '@tiptap/pm/model';
|
||||
import type { Node as PmNode, Schema } from '@tiptap/pm/model';
|
||||
import { blockStyleName } from './paragraphStyle';
|
||||
import type { StyleSheet } from '../../styles/styleSheet';
|
||||
|
||||
// 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; useRegex: boolean; }
|
||||
// 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 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);
|
||||
|
||||
export interface SearchOptions {
|
||||
term: string;
|
||||
matchCase: boolean;
|
||||
wholeWord: boolean;
|
||||
useRegex: boolean;
|
||||
format?: FormatSpec;
|
||||
}
|
||||
// `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[] }
|
||||
@@ -55,28 +74,73 @@ export function regexRanges(text: string, re: RegExp): [number, number, string[]
|
||||
|
||||
// Matches within each textblock, mapped to doc positions. Matches span runs/marks but
|
||||
// never block boundaries (each textblock is searched on its own concatenated text).
|
||||
function findMatches(doc: PmNode, re: RegExp | null): Match[] {
|
||||
if (!re) return [];
|
||||
// A format narrows a text search to the runs carrying it; without a term, those runs —
|
||||
// or, where only a style is asked for, the whole paragraph — are the matches.
|
||||
function findMatches(doc: PmNode, re: RegExp | null, format?: FormatSpec): Match[] {
|
||||
const wanted = markNames(format);
|
||||
if (!re && !hasFormat(format)) return [];
|
||||
const matches: Match[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isTextblock) return true;
|
||||
if (format?.style && blockStyleName(node) !== format.style) return false;
|
||||
let text = '';
|
||||
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));
|
||||
if (child.isText) {
|
||||
const t = child.text ?? '';
|
||||
for (let k = 0; k < t.length; k++) { text += t[k]; map.push(pos + 1 + offset + k); }
|
||||
for (let k = 0; k < t.length; k++) { text += t[k]; map.push(pos + 1 + offset + k); ok.push(marked); }
|
||||
} else {
|
||||
text += ''; // inline atom (image/hardBreak): one non-matching slot
|
||||
map.push(pos + 1 + offset);
|
||||
ok.push(false);
|
||||
}
|
||||
});
|
||||
for (const [s, e, groups] of regexRanges(text, re)) matches.push({ from: map[s], to: map[e - 1] + 1, groups });
|
||||
if (re) {
|
||||
for (const [s, e, groups] of regexRanges(text, re)) {
|
||||
if (ok.slice(s, e).some((v) => !v)) continue;
|
||||
matches.push({ from: map[s], to: map[e - 1] + 1, groups });
|
||||
}
|
||||
} else if (wanted.length) {
|
||||
for (let i = 0; i < ok.length; i++) {
|
||||
if (!ok[i]) continue;
|
||||
let j = i;
|
||||
while (j < ok.length && ok[j]) j++;
|
||||
matches.push({ from: map[i], to: map[j - 1] + 1, groups: [text.slice(i, j)] });
|
||||
i = j;
|
||||
}
|
||||
} else {
|
||||
// Style alone: the paragraph itself is the match, empty ones included.
|
||||
matches.push({ from: pos + 1, to: pos + 1 + node.content.size, groups: [text] });
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return matches;
|
||||
}
|
||||
|
||||
// A replacement's own formatting, applied over the range it landed in.
|
||||
function applyFormat(tr: Transaction, schema: Schema, from: number, to: number, fmt: FormatSpec, sheet: StyleSheet): void {
|
||||
for (const name of markNames(fmt)) {
|
||||
const type = schema.marks[name];
|
||||
if (type) tr.addMark(from, to, type.create());
|
||||
}
|
||||
if (!fmt.style) return;
|
||||
// A heading style also switches the node type, as setParagraphStyle does; anything
|
||||
// else turns a heading back into a paragraph.
|
||||
const level = sheet.paragraph[fmt.style]?.outlineLevel;
|
||||
const type = level && schema.nodes.heading ? schema.nodes.heading : schema.nodes.paragraph;
|
||||
const blocks: { pos: number; node: PmNode }[] = [];
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (!node.isTextblock) return true;
|
||||
if (node.type.name === 'paragraph' || node.type.name === 'heading') blocks.push({ pos, node });
|
||||
return false;
|
||||
});
|
||||
for (const b of blocks) {
|
||||
tr.setNodeMarkup(b.pos, type, { ...b.node.attrs, styleName: fmt.style, ...(level ? { level } : {}) });
|
||||
}
|
||||
}
|
||||
|
||||
function buildDeco(doc: PmNode, matches: Match[], current: number): DecorationSet {
|
||||
if (!matches.length) return DecorationSet.empty;
|
||||
return DecorationSet.create(doc, matches.map((m, i) =>
|
||||
@@ -91,7 +155,7 @@ function pickCurrent(matches: Match[], selFrom: number): number {
|
||||
return i === -1 ? 0 : i;
|
||||
}
|
||||
|
||||
const EMPTY: SearchState = { term: '', matchCase: false, wholeWord: false, useRegex: false, matches: [], current: -1, deco: DecorationSet.empty };
|
||||
const EMPTY: SearchState = { term: '', matchCase: false, wholeWord: false, useRegex: false, format: {}, 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 } {
|
||||
@@ -114,6 +178,32 @@ function moveCurrent(state: EditorState, dispatch: ((tr: Transaction) => void) |
|
||||
return true;
|
||||
}
|
||||
|
||||
// One match rewritten in `tr`, returning the position the replacement ends at. An empty
|
||||
// replacement text with a format keeps the text: that is how both dialogs reformat a
|
||||
// document without retyping it.
|
||||
function replaceOne(
|
||||
tr: Transaction,
|
||||
state: EditorState,
|
||||
m: Match,
|
||||
text: string,
|
||||
useRegex: boolean,
|
||||
format: FormatSpec | undefined,
|
||||
sheet: StyleSheet,
|
||||
): number {
|
||||
const out = useRegex ? expandGroups(text, m.groups) : text;
|
||||
let to = m.to;
|
||||
if (out || !hasFormat(format)) {
|
||||
// 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(Math.min(m.from + 1, m.to)).marks();
|
||||
if (out) tr.replaceWith(m.from, m.to, state.schema.text(out, marks));
|
||||
else tr.delete(m.from, m.to);
|
||||
to = m.from + out.length;
|
||||
}
|
||||
if (format) applyFormat(tr, state.schema, m.from, to, format, sheet);
|
||||
return to;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
searchReplace: {
|
||||
@@ -121,16 +211,20 @@ declare module '@tiptap/core' {
|
||||
findNext: () => ReturnType;
|
||||
findPrevious: () => ReturnType;
|
||||
scrollToCurrent: () => ReturnType;
|
||||
replaceCurrent: (text: string) => ReturnType;
|
||||
replaceAll: (text: string) => ReturnType;
|
||||
replaceCurrent: (text: string, format?: FormatSpec) => ReturnType;
|
||||
replaceAll: (text: string, format?: FormatSpec) => ReturnType;
|
||||
clearSearch: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const SearchReplace = Extension.create({
|
||||
export const SearchReplace = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
name: 'searchReplace',
|
||||
|
||||
addOptions() {
|
||||
return { sheet: () => ({ paragraph: {}, character: {}, table: {} }) };
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<SearchState>({
|
||||
@@ -140,14 +234,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, set.useRegex));
|
||||
const matches = findMatches(newState.doc, buildSearchRegex(set.term, set.matchCase, set.wholeWord, set.useRegex), set.format);
|
||||
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, old.useRegex));
|
||||
if (tr.docChanged && (old.term || hasFormat(old.format))) {
|
||||
const matches = findMatches(newState.doc, buildSearchRegex(old.term, old.matchCase, old.wholeWord, old.useRegex), old.format);
|
||||
const current = pickCurrent(matches, newState.selection.from);
|
||||
return { ...old, matches, current, deco: buildDeco(newState.doc, matches, current) };
|
||||
}
|
||||
@@ -176,37 +270,28 @@ export const SearchReplace = Extension.create({
|
||||
if (dispatch) dispatch(state.tr.setSelection(TextSelection.create(state.doc, m.from, m.to)).scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
replaceCurrent: (text: string) => ({ state, dispatch }) => {
|
||||
replaceCurrent: (text: string, format?: FormatSpec) => ({ state, dispatch }) => {
|
||||
const s = searchKey.getState(state);
|
||||
if (!s || s.current < 0 || !s.matches.length) return false;
|
||||
const m = s.matches[s.current];
|
||||
if (dispatch) {
|
||||
// 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 (out) tr.replaceWith(m.from, m.to, state.schema.text(out, marks));
|
||||
else tr.delete(m.from, m.to);
|
||||
const end = replaceOne(tr, state, m, text, s.useRegex, format, this.options.sheet());
|
||||
// Land the cursor after the replacement so the recompute picks the next match.
|
||||
tr.setSelection(TextSelection.create(tr.doc, m.from + out.length));
|
||||
tr.setSelection(TextSelection.create(tr.doc, end));
|
||||
tr.scrollIntoView();
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
replaceAll: (text: string) => ({ state, dispatch }) => {
|
||||
replaceAll: (text: string, format?: FormatSpec) => ({ state, dispatch }) => {
|
||||
const s = searchKey.getState(state);
|
||||
if (!s || !s.matches.length) return false;
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
// Reverse order so earlier positions stay valid as we splice.
|
||||
for (let i = s.matches.length - 1; i >= 0; i--) {
|
||||
const m = s.matches[i];
|
||||
const marks = state.doc.resolve(m.from + 1).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);
|
||||
replaceOne(tr, state, s.matches[i], text, s.useRegex, format, this.options.sheet());
|
||||
}
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
@@ -476,6 +476,11 @@ const de: Messages = {
|
||||
matchCase: 'Groß-/Kleinschreibung beachten',
|
||||
wholeWord: 'Ganzes Wort',
|
||||
regex: 'Regulärer Ausdruck ($1…$9 im Ersetzungstext)',
|
||||
format: 'Formatierung suchen und ersetzen',
|
||||
formatFind: 'Suchen:',
|
||||
formatReplace: 'Ersetzen:',
|
||||
paragraphStyle: 'Absatzvorlage',
|
||||
anyStyle: 'Beliebige Vorlage',
|
||||
previous: 'Zurück',
|
||||
previousAria: 'Vorheriger Treffer',
|
||||
next: 'Weiter',
|
||||
|
||||
@@ -480,6 +480,11 @@ const en = {
|
||||
matchCase: 'Match case',
|
||||
wholeWord: 'Whole word',
|
||||
regex: 'Regular expression ($1…$9 in the replacement)',
|
||||
format: 'Search and replace formatting',
|
||||
formatFind: 'Find:',
|
||||
formatReplace: 'Replace:',
|
||||
paragraphStyle: 'Paragraph style',
|
||||
anyStyle: 'Any style',
|
||||
previous: 'Previous',
|
||||
previousAria: 'Previous match',
|
||||
next: 'Next',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Find & Replace by formatting and by paragraph style: a format narrows a text search,
|
||||
// and with no term it is the search. A replacement with no text only reformats — which
|
||||
// is how both dialogs restyle a document without retyping it.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Text from '@tiptap/extension-text';
|
||||
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 { SearchReplace, getSearchState } from '../../src/lib/editor/extensions/searchReplace';
|
||||
import { ParagraphStyle } from '../../src/lib/editor/extensions/paragraphStyle';
|
||||
import { builtinStyleSheet } from '../../src/lib/styles/styleSheet';
|
||||
|
||||
type N = any;
|
||||
|
||||
const sheet = () => builtinStyleSheet();
|
||||
const B = (text: string): N => ({ type: 'text', text, marks: [{ type: 'bold' }] });
|
||||
const P = (styleName: string | null, ...content: N[]): N =>
|
||||
({ type: 'paragraph', attrs: { styleName }, content });
|
||||
|
||||
function makeEditor(content: N) {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return new Editor({
|
||||
element: el,
|
||||
extensions: [
|
||||
Document, Paragraph, Text, Heading, Bold, Italic, Underline,
|
||||
ParagraphStyle.configure({ sheet }), SearchReplace.configure({ sheet }),
|
||||
],
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
const doc: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
P(null, { type: 'text', text: 'one ' }, B('two'), { type: 'text', text: ' three' }),
|
||||
P('Quotations', { type: 'text', text: 'two more' }),
|
||||
P('Quotations', B('two')),
|
||||
],
|
||||
};
|
||||
|
||||
const texts = (e: Editor): string[] => e.state.doc.children.map((n: N) => n.textContent);
|
||||
const styles = (e: Editor): (string | null)[] =>
|
||||
e.state.doc.children.map((n: N) => n.attrs.styleName ?? null);
|
||||
|
||||
describe('search by formatting', () => {
|
||||
it('narrows a text search to the runs carrying the format', () => {
|
||||
const e = makeEditor(doc);
|
||||
e.commands.setSearch({ term: 'two', matchCase: false, wholeWord: false, useRegex: false });
|
||||
expect(getSearchState(e.state).count).toBe(3);
|
||||
e.commands.setSearch({ term: 'two', matchCase: false, wholeWord: false, useRegex: false, format: { bold: true } });
|
||||
expect(getSearchState(e.state).count).toBe(2);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it('makes the format the search where there is no term', () => {
|
||||
const e = makeEditor(doc);
|
||||
e.commands.setSearch({ term: '', matchCase: false, wholeWord: false, useRegex: false, format: { bold: true } });
|
||||
expect(getSearchState(e.state).count).toBe(2);
|
||||
// Nothing asked for is still no search at all.
|
||||
e.commands.setSearch({ term: '', matchCase: false, wholeWord: false, useRegex: false, format: {} });
|
||||
expect(getSearchState(e.state).count).toBe(0);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it('finds every paragraph in a style, the empty one included', () => {
|
||||
const e = makeEditor({ ...doc, content: [...doc.content, P('Quotations')] });
|
||||
e.commands.setSearch({ term: '', matchCase: false, wholeWord: false, useRegex: false, format: { style: 'Quotations' } });
|
||||
expect(getSearchState(e.state).count).toBe(3);
|
||||
e.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace by formatting', () => {
|
||||
it('keeps the text and only applies the format when the replacement is empty', () => {
|
||||
const e = makeEditor(doc);
|
||||
e.commands.setSearch({ term: 'two', matchCase: false, wholeWord: false, useRegex: false });
|
||||
e.commands.replaceAll('', { italic: true });
|
||||
expect(texts(e)).toEqual(['one two three', 'two more', 'two']);
|
||||
const marks = e.state.doc.firstChild!.child(1).marks.map((m: N) => m.type.name).sort();
|
||||
expect(marks).toEqual(['bold', 'italic']);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it('restyles the paragraphs a style search found', () => {
|
||||
const e = makeEditor(doc);
|
||||
e.commands.setSearch({ term: '', matchCase: false, wholeWord: false, useRegex: false, format: { style: 'Quotations' } });
|
||||
e.commands.replaceAll('', { style: 'Standard' });
|
||||
expect(styles(e)).toEqual([null, 'Standard', 'Standard']);
|
||||
expect(texts(e)).toEqual(['one two three', 'two more', 'two']);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it('switches the node type where the new style is a heading', () => {
|
||||
const e = makeEditor(doc);
|
||||
e.commands.setSearch({ term: '', matchCase: false, wholeWord: false, useRegex: false, format: { style: 'Quotations' } });
|
||||
e.commands.replaceAll('', { style: 'Heading 2' });
|
||||
const kinds = e.state.doc.children.map((n: N) => `${n.type.name}${n.attrs.level ?? ''}`);
|
||||
expect(kinds).toEqual(['paragraph', 'heading2', 'heading2']);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it('still replaces the text where one is given', () => {
|
||||
const e = makeEditor(doc);
|
||||
e.commands.setSearch({ term: 'two', matchCase: false, wholeWord: false, useRegex: false, format: { bold: true } });
|
||||
e.commands.replaceAll('TWO', { underline: true });
|
||||
expect(texts(e)).toEqual(['one TWO three', 'two more', 'TWO']);
|
||||
const marks = e.state.doc.firstChild!.child(1).marks.map((m: N) => m.type.name).sort();
|
||||
expect(marks).toEqual(['bold', 'underline']);
|
||||
e.destroy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user