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
@@ -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