refactor: what the selection formats becomes a shared reader
Font, size, mark colour and block attributes all answer the same question — the one value the selection agrees on, blank when it mixes. One `uniform` walk now serves all four, framework-free, so any chrome can read it inside its own reactive wrapper. The classic toolbar keeps its own copies for now: it is the baseline the ribbon is compared against, and it should adopt these only once that comparison ends.
This commit is contained in:
1 parent
2aeac7a574
commit
f7ee9546a4
2 files changed
+182
No files matched your search
@@ -0,0 +1,85 @@
|
||||
// What the selection formats uniformly. Each reader returns the shared value,
|
||||
// '' when the selection mixes two, and null where "no value" is meaningful.
|
||||
// Framework-free, so a toolbar reads them inside its own reactive wrapper.
|
||||
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import { blockFontSize, DEFAULT_FONT_SIZE, type SizedBlock } from './fontSize';
|
||||
|
||||
export const DEFAULT_EDITOR_FONT = 'Liberation Serif';
|
||||
|
||||
type MarkedNode = {
|
||||
isText: boolean;
|
||||
isInline: boolean;
|
||||
isAtom: boolean;
|
||||
attrs: Record<string, unknown>;
|
||||
marks: readonly { type: { name: string }; attrs: Record<string, string> }[];
|
||||
};
|
||||
|
||||
// A node that carries text formatting: a text node, or an inline atom (a date
|
||||
// field, say) bearing the mark. Selecting such an atom makes a NodeSelection, so
|
||||
// without this its font, size and colour would all read as the default.
|
||||
export function bearsMark(node: MarkedNode, markName: string): boolean {
|
||||
return node.isText || (node.isInline && node.isAtom && node.marks.some((m) => m.type.name === markName));
|
||||
}
|
||||
|
||||
// Walks the selection and returns the one value every node agrees on, '' if they
|
||||
// disagree. `read` returns undefined for a node that carries no opinion.
|
||||
function uniform<T>(state: EditorState, read: (node: MarkedNode, parent: MarkedNode | null) => T | undefined): T | '' | undefined {
|
||||
const { from, to } = state.selection;
|
||||
let value: T | undefined;
|
||||
let mixed = false;
|
||||
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
||||
if (mixed) return;
|
||||
const v = read(node as unknown as MarkedNode, parent as unknown as MarkedNode | null);
|
||||
if (v === undefined) return;
|
||||
if (value === undefined) value = v;
|
||||
else if (value !== v) mixed = true;
|
||||
});
|
||||
return mixed ? '' : value;
|
||||
}
|
||||
|
||||
function storedMarkAttr(state: EditorState, markName: string, attr: string): string | undefined {
|
||||
const marks = state.storedMarks ?? state.selection.$head.marks();
|
||||
return marks.find((m) => m.type.name === markName)?.attrs[attr] as string | undefined;
|
||||
}
|
||||
|
||||
export function uniformFont(state: EditorState): string {
|
||||
if (state.selection.empty) return storedMarkAttr(state, 'textStyle', 'fontFamily') ?? DEFAULT_EDITOR_FONT;
|
||||
const v = uniform<string>(state, (node) =>
|
||||
bearsMark(node, 'textStyle')
|
||||
? (node.marks.find((m) => m.type.name === 'textStyle')?.attrs.fontFamily ?? DEFAULT_EDITOR_FONT)
|
||||
: undefined);
|
||||
return v ?? DEFAULT_EDITOR_FONT;
|
||||
}
|
||||
|
||||
export function uniformFontSize(state: EditorState): string {
|
||||
if (state.selection.empty) {
|
||||
const head = state.selection.$head;
|
||||
return storedMarkAttr(state, 'textStyle', 'fontSize') ?? blockFontSize(head.parent as unknown as SizedBlock);
|
||||
}
|
||||
const v = uniform<string>(state, (node, parent) => {
|
||||
if (!bearsMark(node, 'textStyle')) return undefined;
|
||||
const explicit = node.marks.find((m) => m.type.name === 'textStyle')?.attrs.fontSize;
|
||||
return explicit || blockFontSize(parent as unknown as SizedBlock);
|
||||
});
|
||||
return v ?? DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
// null = no colour anywhere. Font colour rides the textStyle mark (fontColor.ts),
|
||||
// highlight its own multicolor mark.
|
||||
export function uniformMarkColor(state: EditorState, markName: string): string | null {
|
||||
if (state.selection.empty) return storedMarkAttr(state, markName, 'color') ?? null;
|
||||
const v = uniform<string | null>(state, (node) =>
|
||||
bearsMark(node, markName)
|
||||
? (node.marks.find((m) => m.type.name === markName)?.attrs.color ?? null)
|
||||
: undefined);
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
// A block attribute every selected block agrees on (lineHeight, spaceBefore,
|
||||
// backgroundColor …). `fallback` is what a block without the attribute reports.
|
||||
export function uniformBlockAttr<T>(state: EditorState, attr: string, fallback: T): T | '' {
|
||||
if (state.selection.empty) return (state.selection.$head.parent.attrs[attr] ?? fallback) as T;
|
||||
const v = uniform<T>(state, (node) => (attr in node.attrs ? ((node.attrs[attr] ?? fallback) as T) : undefined));
|
||||
return v === undefined ? fallback : v;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// What a toolbar shows for the selection. Drives a real body editor, so the
|
||||
// readers see the same state the ribbon does.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { extensions } from '../../src/lib/editor/extensions';
|
||||
import {
|
||||
DEFAULT_EDITOR_FONT, uniformFont, uniformFontSize, uniformMarkColor, uniformBlockAttr,
|
||||
} from '../../src/lib/utils/selectionFormat';
|
||||
|
||||
type N = any;
|
||||
|
||||
function makeEditor(content: N) {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return new Editor({ element: el, extensions, content });
|
||||
}
|
||||
|
||||
const run = (text: string, attrs?: N): N => ({
|
||||
type: 'text', text, ...(attrs ? { marks: [{ type: 'textStyle', attrs }] } : {}),
|
||||
});
|
||||
|
||||
const doc = (...paras: N[]): N => ({ type: 'doc', content: paras });
|
||||
const para = (attrs: N, ...content: N[]): N => ({ type: 'paragraph', attrs, content });
|
||||
|
||||
describe('uniformFont', () => {
|
||||
it('falls back to the editor default where no run sets one', () => {
|
||||
const editor = makeEditor(doc(para({}, run('plain'))));
|
||||
editor.commands.selectAll();
|
||||
expect(uniformFont(editor.state)).toBe(DEFAULT_EDITOR_FONT);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('reports one font across a selection and blanks a mixed one', () => {
|
||||
const editor = makeEditor(doc(para({}, run('aa', { fontFamily: 'Arial' }), run('bb', { fontFamily: 'Arial' }))));
|
||||
editor.commands.selectAll();
|
||||
expect(uniformFont(editor.state)).toBe('Arial');
|
||||
|
||||
const mixed = makeEditor(doc(para({}, run('aa', { fontFamily: 'Arial' }), run('bb', { fontFamily: 'Georgia' }))));
|
||||
mixed.commands.selectAll();
|
||||
expect(uniformFont(mixed.state)).toBe('');
|
||||
editor.destroy();
|
||||
mixed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniformFontSize', () => {
|
||||
it('prefers a run size over the block size', () => {
|
||||
const editor = makeEditor(doc(para({ fontSize: '20pt' }, run('big', { fontSize: '9pt' }))));
|
||||
editor.commands.selectAll();
|
||||
expect(uniformFontSize(editor.state)).toBe('9pt');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('reads the block size for a run that carries none', () => {
|
||||
const editor = makeEditor(doc(para({ fontSize: '20pt' }, run('plain'))));
|
||||
editor.commands.selectAll();
|
||||
expect(uniformFontSize(editor.state)).toBe('20pt');
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniformMarkColor', () => {
|
||||
it('is null without a colour, the colour with one, blank when mixed', () => {
|
||||
const none = makeEditor(doc(para({}, run('plain'))));
|
||||
none.commands.selectAll();
|
||||
expect(uniformMarkColor(none.state, 'textStyle')).toBeNull();
|
||||
|
||||
const one = makeEditor(doc(para({}, run('red', { color: '#ff0000' }))));
|
||||
one.commands.selectAll();
|
||||
expect(uniformMarkColor(one.state, 'textStyle')).toBe('#ff0000');
|
||||
|
||||
const two = makeEditor(doc(para({}, run('r', { color: '#ff0000' }), run('b', { color: '#0000ff' }))));
|
||||
two.commands.selectAll();
|
||||
expect(uniformMarkColor(two.state, 'textStyle')).toBe('');
|
||||
none.destroy(); one.destroy(); two.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniformBlockAttr', () => {
|
||||
it('agrees across blocks and blanks when they differ', () => {
|
||||
const same = makeEditor(doc(para({ lineHeight: '1.5' }, run('a')), para({ lineHeight: '1.5' }, run('b'))));
|
||||
same.commands.selectAll();
|
||||
expect(uniformBlockAttr(same.state, 'lineHeight', '1')).toBe('1.5');
|
||||
|
||||
const differ = makeEditor(doc(para({ lineHeight: '1.5' }, run('a')), para({ lineHeight: '2' }, run('b'))));
|
||||
differ.commands.selectAll();
|
||||
expect(uniformBlockAttr(differ.state, 'lineHeight', '1')).toBe('');
|
||||
same.destroy(); differ.destroy();
|
||||
});
|
||||
|
||||
it('reports the fallback for a block that never sets the attribute', () => {
|
||||
const editor = makeEditor(doc(para({}, run('a'))));
|
||||
editor.commands.selectAll();
|
||||
expect(uniformBlockAttr(editor.state, 'lineHeight', '1')).toBe('1');
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user