Files
edentext/tests/unit/search-format.test.ts
T
Steffen Becker 2f087bc880 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.
2026-08-15 10:19:48 +02:00

117 lines
5.1 KiB
TypeScript

// 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();
});
});