From f7ee9546a4ed362901e56d04938d6c01e3527cb0 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:22:52 +0200 Subject: [PATCH] refactor: what the selection formats becomes a shared reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/utils/selectionFormat.ts | 85 +++++++++++++++++++++++++ tests/unit/selection-format.test.ts | 97 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/lib/utils/selectionFormat.ts create mode 100644 tests/unit/selection-format.test.ts diff --git a/src/lib/utils/selectionFormat.ts b/src/lib/utils/selectionFormat.ts new file mode 100644 index 0000000..7b721ff --- /dev/null +++ b/src/lib/utils/selectionFormat.ts @@ -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; + marks: readonly { type: { name: string }; attrs: Record }[]; +}; + +// 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(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(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(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(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(state: EditorState, attr: string, fallback: T): T | '' { + if (state.selection.empty) return (state.selection.$head.parent.attrs[attr] ?? fallback) as T; + const v = uniform(state, (node) => (attr in node.attrs ? ((node.attrs[attr] ?? fallback) as T) : undefined)); + return v === undefined ? fallback : v; +} diff --git a/tests/unit/selection-format.test.ts b/tests/unit/selection-format.test.ts new file mode 100644 index 0000000..423ce6f --- /dev/null +++ b/tests/unit/selection-format.test.ts @@ -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(); + }); +});