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.
156 lines
6.7 KiB
TypeScript
156 lines
6.7 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 { 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';
|
|
|
|
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,
|
|
TextStyle, FontFamily, FontSize, FontColor,
|
|
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('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);
|
|
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();
|
|
});
|
|
});
|