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:
Steffen Becker committed 2026-08-12 01:22:52 +02:00
1 parent 2aeac7a574
commit f7ee9546a4
2 files changed
+182

No files matched your search

+85
View File
@@ -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;
}
+97
View File
@@ -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();
});
});