From 819f51587eccdff7ef8d691f0c1cc2ee2ab22f3f Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 18:08:13 +0000 Subject: [PATCH] fix: direct formatting on a run beats its named character style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A character style is a stylesheet rule, direct formatting an inline style on the run's span. The charStyle mark ranked below TextStyle, so its span sat *inside* and the rule won over the ancestor's inline style — a 9pt style silently undid a run's own 10pt. Priority 102 lifts it above TextStyle's 101. One fixture: 259 → 172 parity issues, lineEnd 49 → 7. --- src/lib/editor/extensions/characterStyle.ts | 4 ++++ src/lib/styles/CLAUDE.md | 3 ++- tests/unit/mark-order.test.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/unit/mark-order.test.ts diff --git a/src/lib/editor/extensions/characterStyle.ts b/src/lib/editor/extensions/characterStyle.ts index 517a70f..3ee3ab9 100644 --- a/src/lib/editor/extensions/characterStyle.ts +++ b/src/lib/editor/extensions/characterStyle.ts @@ -16,6 +16,10 @@ declare module '@tiptap/core' { export const CharacterStyle = Mark.create({ name: 'charStyle', + // Above TextStyle's 101, so this span wraps the direct formatting's instead of sitting + // inside it: the style is a stylesheet rule, and a rule beats an ancestor's inline style. + priority: 102, + addAttributes() { return { name: { diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index ee7dbd4..88e568e 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -56,7 +56,8 @@ holds it as a reactive singleton persisted to `odf-editor-styles` (same shape as declares no style; `tests/unit/style-resolve.test.ts` asserts the built-ins match it. - **Character styles** are the second family (`sheet.character`, LibreOffice's Emphasis / Strong Emphasis / Source Text): the `CharacterStyle` mark (`charStyle`, attr `name`) tags a run - and renders `data-char-style`, `styleCss` emits a rule per style, and the gallery lists them + and renders `data-char-style` (`priority: 102`, so the span wraps the direct formatting's — + a stylesheet rule beats an ancestor's inline style), `styleCss` emits a rule per style, and the gallery lists them under the paragraph styles (clicking the active one toggles it off). **ODF**: export bakes the style's resolved formatting onto the run *and* prefixes a `CST` sentinel (U+E00E) so `applyCharacterStyles` re-points that span at a clone whose parent is the named diff --git a/tests/unit/mark-order.test.ts b/tests/unit/mark-order.test.ts new file mode 100644 index 0000000..aedb72c --- /dev/null +++ b/tests/unit/mark-order.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { getSchema, generateHTML } from '@tiptap/core'; +import { extensions } from '../../src/lib/editor/extensions'; + +// A named character style renders as a stylesheet rule, direct formatting as an inline +// style — so the charStyle span has to sit outside, or the rule beats the ancestor. +describe('mark nesting', () => { + it('puts charStyle outside textStyle', () => { + const schema = getSchema(extensions); + expect(schema.marks.charStyle.rank).toBeLessThan(schema.marks.textStyle.rank); + const html = generateHTML({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x', + marks: [{ type: 'textStyle', attrs: { fontSize: '10pt' } }, { type: 'charStyle', attrs: { name: 'Code' } }] }] }], + }, extensions); + expect(html.indexOf('data-char-style')).toBeLessThan(html.indexOf('font-size')); + }); +});