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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user