From c537c60be7ec99d2fbb1179512d2a754adb1c7ad Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 14:00:43 -0700 Subject: [PATCH 1/3] fix(document-api): clear styles before paragraph.setStyle --- .gitignore | 2 + .../plan-engine/paragraphs-wrappers.test.ts | 57 ++++++++++++++-- .../plan-engine/paragraphs-wrappers.ts | 68 ++++++++++++++++++- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e8bfe603c1..25195c8d14 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,5 @@ packages/sdk/langs/python/superdoc/generated/ packages/sdk/tools/*.json # Note: apps/docs/document-api/reference/ stays committed because Mintlify # deploys directly from git with no pre-build hook support. + +.playwright-cli/ diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts index 6215da3ea8..87c8bbef87 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts @@ -21,13 +21,15 @@ vi.mock('./plan-wrappers.js', () => ({ }), })); -import { paragraphsSetIndentationWrapper } from './paragraphs-wrappers.js'; +import { paragraphsSetIndentationWrapper, paragraphsSetStyleWrapper } from './paragraphs-wrappers.js'; type MockNode = { - type: { name: 'paragraph' }; - isBlock: true; + type: { name: 'paragraph' | 'text' }; + isBlock?: true; + isText?: true; nodeSize: number; attrs: Record; + marks?: Array<{ type: { name: string } }>; }; function createParagraphNode(attrs: Record): MockNode { @@ -39,19 +41,41 @@ function createParagraphNode(attrs: Record): MockNode { }; } -function makeEditor(paragraphProperties: Record): { +function makeEditor( + paragraphProperties: Record, + textMarks: Array<{ type: { name: string } }> = [], +): { editor: Editor; setNodeMarkup: ReturnType; + removeMark: ReturnType; } { const paragraphNode = createParagraphNode({ paraId: 'p1', sdBlockId: 'p1', paragraphProperties, }); + paragraphNode.nodeSize = 6; + + const textNode: MockNode = { + type: { name: 'text' }, + isText: true, + nodeSize: 4, + attrs: {}, + marks: textMarks, + }; const setNodeMarkup = vi.fn().mockReturnThis(); + const removeMark = vi.fn().mockReturnThis(); const tr = { setNodeMarkup, + removeMark, + doc: { + nodesBetween(callbackStart: number, callbackEnd: number, callback: (node: MockNode, pos: number) => void) { + if (callbackStart < callbackEnd) { + callback(textNode, 1); + } + }, + }, }; const doc = { @@ -61,6 +85,11 @@ function makeEditor(paragraphProperties: Record): { nodeAt(pos: number) { return pos === 0 ? paragraphNode : null; }, + nodesBetween(from: number, to: number, callback: (node: MockNode, pos: number) => void) { + if (from < to) { + callback(textNode, 1); + } + }, }; const editor = { @@ -69,7 +98,7 @@ function makeEditor(paragraphProperties: Record): { commands: {}, } as unknown as Editor; - return { editor, setNodeMarkup }; + return { editor, setNodeMarkup, removeMark }; } describe('paragraphsSetIndentationWrapper', () => { @@ -101,3 +130,21 @@ describe('paragraphsSetIndentationWrapper', () => { expect(nextAttrs.paragraphProperties.indent).toEqual({ right: 120, hanging: 360 }); }); }); + +describe('paragraphsSetStyleWrapper', () => { + it('clears linked-style formatting marks while setting the paragraph style', () => { + const textStyleMark = { type: { name: 'textStyle' } }; + const hyperlinkMark = { type: { name: 'link' } }; + const { editor, setNodeMarkup, removeMark } = makeEditor({}, [textStyleMark, hyperlinkMark]); + + paragraphsSetStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + styleId: 'Heading1', + }); + + expect(removeMark).toHaveBeenCalledTimes(1); + expect(removeMark).toHaveBeenCalledWith(1, 5, textStyleMark); + const nextAttrs = setNodeMarkup.mock.calls[0]?.[2] as { paragraphProperties: Record }; + expect(nextAttrs.paragraphProperties).toEqual({ styleId: 'Heading1' }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts index 4c3e0b5a7b..c00bcde92d 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts @@ -48,6 +48,16 @@ import { executeDomainCommand } from './plan-wrappers.js'; // --------------------------------------------------------------------------- const PARAGRAPH_NODE_TYPES = new Set(['paragraph', 'heading', 'listItem']); +const LINKED_STYLE_FORMATTING_MARK_NAMES = new Set([ + 'textStyle', + 'bold', + 'italic', + 'underline', + 'strike', + 'subscript', + 'superscript', + 'highlight', +]); // --------------------------------------------------------------------------- // Target resolution @@ -83,6 +93,44 @@ function noOpResult(operation: string): ParagraphMutationResult { }; } +function clearFormattingMarksInBlock( + tr: { + doc?: { + nodesBetween?: ( + from: number, + to: number, + callback: ( + node: { isText?: boolean; marks?: Array<{ type?: { name?: string } }>; nodeSize?: number }, + pos: number, + ) => boolean | void, + ) => void; + }; + removeMark?: (from: number, to: number, mark: unknown) => unknown; + }, + pos: number, + nodeSize: number, +): boolean { + if (!tr.doc?.nodesBetween || !tr.removeMark || nodeSize <= 2) return false; + + let changed = false; + tr.doc.nodesBetween(pos + 1, pos + nodeSize - 1, (node, nodePos) => { + if (!node.isText || !Array.isArray(node.marks) || node.marks.length === 0 || typeof node.nodeSize !== 'number') { + return true; + } + + node.marks.forEach((mark) => { + const markName = mark?.type?.name; + if (!markName || !LINKED_STYLE_FORMATTING_MARK_NAMES.has(markName)) return; + tr.removeMark?.(nodePos, nodePos + node.nodeSize!, mark); + changed = true; + }); + + return true; + }); + + return changed; +} + // --------------------------------------------------------------------------- // Core mutation helper — transforms paragraphProperties on a resolved block // --------------------------------------------------------------------------- @@ -101,6 +149,9 @@ function mutateParagraphProperties( target: ParagraphTarget, transform: (pPr: PPr) => PPr, options?: MutationOptions, + extras?: { + clearFormattingMarks?: boolean; + }, ): ParagraphMutationResult { if (options?.dryRun) return successResult(target); @@ -113,10 +164,20 @@ function mutateParagraphProperties( const existing = (node.attrs as { paragraphProperties?: PPr }).paragraphProperties ?? {}; const updated = transform({ ...existing }); - if (JSON.stringify(existing) === JSON.stringify(updated)) return false; - const tr = editor.state.tr; - tr.setNodeMarkup(candidate.pos, undefined, { ...node.attrs, paragraphProperties: updated }); + let changed = false; + + if (extras?.clearFormattingMarks) { + changed = clearFormattingMarksInBlock(tr, candidate.pos, node.nodeSize) || changed; + } + + if (JSON.stringify(existing) !== JSON.stringify(updated)) { + tr.setNodeMarkup(candidate.pos, undefined, { ...node.attrs, paragraphProperties: updated }); + changed = true; + } + + if (!changed) return false; + editor.dispatch(tr); clearIndexCache(editor); return true; @@ -220,6 +281,7 @@ export function paragraphsSetStyleWrapper( styleId: input.styleId, }), options, + { clearFormattingMarks: true }, ); } From 8daf5b4fd71a0e9d06e7d751f8291d91ff3fab32 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 14:18:53 -0700 Subject: [PATCH 2/3] chore: fix type --- .../document-api-adapters/plan-engine/paragraphs-wrappers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts index c00bcde92d..0b71f9186c 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts @@ -100,7 +100,7 @@ function clearFormattingMarksInBlock( from: number, to: number, callback: ( - node: { isText?: boolean; marks?: Array<{ type?: { name?: string } }>; nodeSize?: number }, + node: { isText?: boolean; marks?: ReadonlyArray<{ type?: { name?: string } }>; nodeSize?: number }, pos: number, ) => boolean | void, ) => void; From 96145bffd9b198d119fc817668895f6c8fd6477c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 14:56:42 -0700 Subject: [PATCH 3/3] fix(document-api): align paragraph.setStyle with Word semantics --- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 4 +- apps/docs/document-api/reference/info.mdx | 11 +- .../reference/styles/paragraph/set-style.mdx | 6 +- .../src/contract/operation-definitions.ts | 6 +- .../document-api/src/paragraphs/paragraphs.ts | 2 +- .../plan-engine/paragraphs-wrappers.test.ts | 86 +++++++++++++-- .../plan-engine/paragraphs-wrappers.ts | 102 ++++++++++++------ 8 files changed, 168 insertions(+), 51 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index cb498a211a..104d6f07a8 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -962,5 +962,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "42294181d9125c3dfb3525be01eb6c645c3a18d511bd95d678d6661920490721" + "sourceHash": "7c98e8a222685ebb7801111b454a2e5bc6ef18b1ce4a9d7d576a5e48aa69bd5f" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 9fe319813b..a45e8c27e9 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -68,7 +68,7 @@ The tables below are grouped by namespace. | getMarkdown | editor.doc.getMarkdown(...) | Extract the document content as a Markdown string. | | getHtml | editor.doc.getHtml(...) | Extract the document content as an HTML string. | | markdownToFragment | editor.doc.markdownToFragment(...) | Convert a Markdown string into an SDM/1 structural fragment. | -| info | editor.doc.info(...) | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. | +| info | editor.doc.info(...) | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. | | clearContent | editor.doc.clearContent(...) | Clear all document body content, leaving a single empty paragraph. | | insert | editor.doc.insert(...) | Insert content into the document. Two input shapes: legacy string-based (value + type) inserts inline content at a text position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target is omitted, content appends at the end of the document. Legacy mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | replace | editor.doc.replace(...) | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | @@ -269,7 +269,7 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | -| styles.paragraph.setStyle | editor.doc.styles.paragraph.setStyle(...) | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| styles.paragraph.setStyle | editor.doc.styles.paragraph.setStyle(...) | Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references. | | styles.paragraph.clearStyle | editor.doc.styles.paragraph.clearStyle(...) | Remove the paragraph style reference from a paragraph-like block. | #### Tables diff --git a/apps/docs/document-api/reference/info.mdx b/apps/docs/document-api/reference/info.mdx index 8b4c4d0459..28db0e02ca 100644 --- a/apps/docs/document-api/reference/info.mdx +++ b/apps/docs/document-api/reference/info.mdx @@ -1,7 +1,7 @@ --- title: info sidebarTitle: info -description: Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. +description: Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Return document summary info including word, character, paragraph, ## Summary -Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. +Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. - Operation ID: `info` - API member path: `editor.doc.info(...)` @@ -22,7 +22,7 @@ Return document summary info including word, character, paragraph, heading, tabl ## Expected result -Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists), document outline, capability flags, and revision. +Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists, and optionally pages when pagination is active), document outline, capability flags, and revision. ## Input fields @@ -49,6 +49,7 @@ _No fields._ | `counts.headings` | integer | yes | | | `counts.images` | integer | yes | | | `counts.lists` | integer | yes | | +| `counts.pages` | integer | no | | | `counts.paragraphs` | integer | yes | | | `counts.sdtFields` | integer | yes | | | `counts.tables` | integer | yes | | @@ -73,6 +74,7 @@ _No fields._ "headings": 3, "images": 2, "lists": 1, + "pages": 1, "paragraphs": 12, "sdtFields": 1, "tables": 1, @@ -157,6 +159,9 @@ _No fields._ "lists": { "type": "integer" }, + "pages": { + "type": "integer" + }, "paragraphs": { "type": "integer" }, diff --git a/apps/docs/document-api/reference/styles/paragraph/set-style.mdx b/apps/docs/document-api/reference/styles/paragraph/set-style.mdx index 71daea551b..4a4477bdd0 100644 --- a/apps/docs/document-api/reference/styles/paragraph/set-style.mdx +++ b/apps/docs/document-api/reference/styles/paragraph/set-style.mdx @@ -1,7 +1,7 @@ --- title: styles.paragraph.setStyle sidebarTitle: styles.paragraph.setStyle -description: "Set the paragraph style reference (w:pStyle) on a paragraph-like block." +description: "Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: "Set the paragraph style reference (w:pStyle) on a paragraph-like b ## Summary -Set the paragraph style reference (w:pStyle) on a paragraph-like block. +Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references. - Operation ID: `styles.paragraph.setStyle` - API member path: `editor.doc.styles.paragraph.setStyle(...)` @@ -22,7 +22,7 @@ Set the paragraph style reference (w:pStyle) on a paragraph-like block. ## Expected result -Returns a ParagraphMutationResult; reports NO_OP if the style already matches. +Returns a ParagraphMutationResult; reports NO_OP if the style already matches. When the style changes, direct run formatting is cleared while character-style references are preserved. ## Input fields diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 96aefb2af3..1f59e228b0 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -963,8 +963,10 @@ export const OPERATION_DEFINITIONS = { 'styles.paragraph.setStyle': { memberPath: 'styles.paragraph.setStyle', - description: 'Set the paragraph style reference (w:pStyle) on a paragraph-like block.', - expectedResult: 'Returns a ParagraphMutationResult; reports NO_OP if the style already matches.', + description: + 'Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references.', + expectedResult: + 'Returns a ParagraphMutationResult; reports NO_OP if the style already matches. When the style changes, direct run formatting is cleared while character-style references are preserved.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', diff --git a/packages/document-api/src/paragraphs/paragraphs.ts b/packages/document-api/src/paragraphs/paragraphs.ts index 6fa93ea770..1c88f0a0c0 100644 --- a/packages/document-api/src/paragraphs/paragraphs.ts +++ b/packages/document-api/src/paragraphs/paragraphs.ts @@ -136,7 +136,7 @@ export interface ParagraphFormatApi { clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult; } -/** Public API surface for `styles.paragraph.*` — paragraph style reference operations. */ +/** Public API surface for `styles.paragraph.*` — Word-like paragraph style application operations. */ export interface ParagraphStylesApi { setStyle(input: ParagraphsSetStyleInput, options?: MutationOptions): ParagraphMutationResult; clearStyle(input: ParagraphsClearStyleInput, options?: MutationOptions): ParagraphMutationResult; diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts index 87c8bbef87..ccf0f6e73b 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts @@ -29,7 +29,7 @@ type MockNode = { isText?: true; nodeSize: number; attrs: Record; - marks?: Array<{ type: { name: string } }>; + marks?: MockMark[]; }; function createParagraphNode(attrs: Record): MockNode { @@ -41,13 +41,28 @@ function createParagraphNode(attrs: Record): MockNode { }; } +type MockMark = { + type: { name: string; create?: (attrs: Record) => MockMark }; + attrs?: Record; +}; + +function createFormattingMark(name: string, attrs?: Record): MockMark { + return { type: { name }, attrs }; +} + +function createTextStyleMark(attrs?: Record): MockMark { + return createFormattingMark('textStyle', attrs); +} + function makeEditor( paragraphProperties: Record, - textMarks: Array<{ type: { name: string } }> = [], + textMarks: MockMark[] = [], ): { editor: Editor; setNodeMarkup: ReturnType; removeMark: ReturnType; + addMark: ReturnType; + dispatch: ReturnType; } { const paragraphNode = createParagraphNode({ paraId: 'p1', @@ -66,9 +81,11 @@ function makeEditor( const setNodeMarkup = vi.fn().mockReturnThis(); const removeMark = vi.fn().mockReturnThis(); + const addMark = vi.fn().mockReturnThis(); const tr = { setNodeMarkup, removeMark, + addMark, doc: { nodesBetween(callbackStart: number, callbackEnd: number, callback: (node: MockNode, pos: number) => void) { if (callbackStart < callbackEnd) { @@ -98,7 +115,7 @@ function makeEditor( commands: {}, } as unknown as Editor; - return { editor, setNodeMarkup, removeMark }; + return { editor, setNodeMarkup, removeMark, addMark, dispatch: editor.dispatch as ReturnType }; } describe('paragraphsSetIndentationWrapper', () => { @@ -132,19 +149,72 @@ describe('paragraphsSetIndentationWrapper', () => { }); describe('paragraphsSetStyleWrapper', () => { - it('clears linked-style formatting marks while setting the paragraph style', () => { - const textStyleMark = { type: { name: 'textStyle' } }; - const hyperlinkMark = { type: { name: 'link' } }; - const { editor, setNodeMarkup, removeMark } = makeEditor({}, [textStyleMark, hyperlinkMark]); + it('clears direct run formatting while leaving unrelated marks untouched', () => { + const boldMark = createFormattingMark('bold'); + const textStyleMark = createTextStyleMark({ fontFamily: 'Arial', fontSize: '12pt' }); + const hyperlinkMark = createFormattingMark('link'); + const { editor, setNodeMarkup, removeMark, addMark } = makeEditor({}, [boldMark, textStyleMark, hyperlinkMark]); paragraphsSetStyleWrapper(editor, { target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, styleId: 'Heading1', }); - expect(removeMark).toHaveBeenCalledTimes(1); + expect(removeMark).toHaveBeenCalledTimes(2); + expect(removeMark).toHaveBeenCalledWith(1, 5, boldMark); expect(removeMark).toHaveBeenCalledWith(1, 5, textStyleMark); + expect(addMark).not.toHaveBeenCalled(); const nextAttrs = setNodeMarkup.mock.calls[0]?.[2] as { paragraphProperties: Record }; expect(nextAttrs.paragraphProperties).toEqual({ styleId: 'Heading1' }); }); + + it('preserves character-style reference (styleId) on textStyle marks', () => { + const emphasisStyleMark = createTextStyleMark({ styleId: 'Emphasis' }); + const { editor, removeMark, addMark } = makeEditor({}, [emphasisStyleMark]); + + paragraphsSetStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + styleId: 'Heading1', + }); + + // Mark only has styleId (no formatting attrs) — should not be touched at all + expect(removeMark).not.toHaveBeenCalled(); + expect(addMark).not.toHaveBeenCalled(); + }); + + it('strips formatting attrs from textStyle but re-adds styleId', () => { + const createdMark = createTextStyleMark({ styleId: 'Emphasis' }); + const mixedMark: MockMark = { + type: { name: 'textStyle', create: (attrs) => ({ ...createdMark, attrs }) }, + attrs: { styleId: 'Emphasis', fontFamily: 'Arial' }, + }; + const { editor, removeMark, addMark } = makeEditor({}, [mixedMark]); + + paragraphsSetStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + styleId: 'Heading1', + }); + + expect(removeMark).toHaveBeenCalledTimes(1); + expect(removeMark).toHaveBeenCalledWith(1, 5, mixedMark); + expect(addMark).toHaveBeenCalledTimes(1); + expect(addMark).toHaveBeenCalledWith(1, 5, expect.objectContaining({ attrs: { styleId: 'Emphasis' } })); + }); + + it('returns NO_OP when the style already matches', () => { + const boldMark = createFormattingMark('bold'); + const { editor, removeMark, dispatch } = makeEditor({ styleId: 'Normal' }, [boldMark]); + + const result = paragraphsSetStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + styleId: 'Normal', + }); + + expect(result).toEqual({ + success: false, + failure: { code: 'NO_OP', message: 'styles.paragraph.setStyle produced no changes.' }, + }); + expect(removeMark).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts index 0b71f9186c..980e3c901a 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts @@ -48,7 +48,8 @@ import { executeDomainCommand } from './plan-wrappers.js'; // --------------------------------------------------------------------------- const PARAGRAPH_NODE_TYPES = new Set(['paragraph', 'heading', 'listItem']); -const LINKED_STYLE_FORMATTING_MARK_NAMES = new Set([ +const TEXT_STYLE_CHARACTER_STYLE_ATTR = 'styleId'; +const DIRECT_FORMATTING_MARK_NAMES = new Set([ 'textStyle', 'bold', 'italic', @@ -93,23 +94,61 @@ function noOpResult(operation: string): ParagraphMutationResult { }; } -function clearFormattingMarksInBlock( - tr: { - doc?: { - nodesBetween?: ( - from: number, - to: number, - callback: ( - node: { isText?: boolean; marks?: ReadonlyArray<{ type?: { name?: string } }>; nodeSize?: number }, - pos: number, - ) => boolean | void, - ) => void; - }; - removeMark?: (from: number, to: number, mark: unknown) => unknown; - }, - pos: number, - nodeSize: number, +type MarkLike = { + type?: { name?: string; create?: (attrs: Record) => unknown }; + attrs?: Record; +}; + +type TransactionWithMarkMutations = { + doc?: { + nodesBetween?: ( + from: number, + to: number, + callback: ( + node: { isText?: boolean; marks?: ReadonlyArray; nodeSize?: number }, + pos: number, + ) => boolean | void, + ) => void; + }; + removeMark?: (from: number, to: number, mark: unknown) => unknown; + addMark?: (from: number, to: number, mark: unknown) => unknown; +}; + +function getPreservedCharacterStyleAttrs(mark: MarkLike): Record | null { + const styleId = mark.attrs?.[TEXT_STYLE_CHARACTER_STYLE_ATTR]; + if (typeof styleId !== 'string' || styleId.length === 0) return null; + return { [TEXT_STYLE_CHARACTER_STYLE_ATTR]: styleId }; +} + +function hasTextStyleDirectFormatting(mark: MarkLike): boolean { + return Object.entries(mark.attrs ?? {}).some( + ([key, value]) => key !== TEXT_STYLE_CHARACTER_STYLE_ATTR && value != null, + ); +} + +function clearTextStyleDirectFormatting( + tr: TransactionWithMarkMutations, + from: number, + to: number, + mark: MarkLike, ): boolean { + const preservedCharacterStyle = getPreservedCharacterStyleAttrs(mark); + const hadDirectFormatting = hasTextStyleDirectFormatting(mark); + + if (!hadDirectFormatting && preservedCharacterStyle) { + return false; + } + + tr.removeMark?.(from, to, mark); + + if (hadDirectFormatting && preservedCharacterStyle && mark.type?.create && tr.addMark) { + tr.addMark(from, to, mark.type.create(preservedCharacterStyle)); + } + + return true; +} + +function clearDirectFormattingInBlock(tr: TransactionWithMarkMutations, pos: number, nodeSize: number): boolean { if (!tr.doc?.nodesBetween || !tr.removeMark || nodeSize <= 2) return false; let changed = false; @@ -120,8 +159,14 @@ function clearFormattingMarksInBlock( node.marks.forEach((mark) => { const markName = mark?.type?.name; - if (!markName || !LINKED_STYLE_FORMATTING_MARK_NAMES.has(markName)) return; - tr.removeMark?.(nodePos, nodePos + node.nodeSize!, mark); + if (!markName || !DIRECT_FORMATTING_MARK_NAMES.has(markName)) return; + + if (markName === 'textStyle') { + changed = clearTextStyleDirectFormatting(tr, nodePos, nodePos + node.nodeSize!, mark) || changed; + return; + } + + tr.removeMark(nodePos, nodePos + node.nodeSize!, mark); changed = true; }); @@ -150,7 +195,7 @@ function mutateParagraphProperties( transform: (pPr: PPr) => PPr, options?: MutationOptions, extras?: { - clearFormattingMarks?: boolean; + clearDirectFormatting?: boolean; }, ): ParagraphMutationResult { if (options?.dryRun) return successResult(target); @@ -164,20 +209,15 @@ function mutateParagraphProperties( const existing = (node.attrs as { paragraphProperties?: PPr }).paragraphProperties ?? {}; const updated = transform({ ...existing }); - const tr = editor.state.tr; - let changed = false; + if (JSON.stringify(existing) === JSON.stringify(updated)) return false; - if (extras?.clearFormattingMarks) { - changed = clearFormattingMarksInBlock(tr, candidate.pos, node.nodeSize) || changed; - } + const tr = editor.state.tr; - if (JSON.stringify(existing) !== JSON.stringify(updated)) { - tr.setNodeMarkup(candidate.pos, undefined, { ...node.attrs, paragraphProperties: updated }); - changed = true; + if (extras?.clearDirectFormatting) { + clearDirectFormattingInBlock(tr, candidate.pos, node.nodeSize); } - if (!changed) return false; - + tr.setNodeMarkup(candidate.pos, undefined, { ...node.attrs, paragraphProperties: updated }); editor.dispatch(tr); clearIndexCache(editor); return true; @@ -281,7 +321,7 @@ export function paragraphsSetStyleWrapper( styleId: input.styleId, }), options, - { clearFormattingMarks: true }, + { clearDirectFormatting: true }, ); }