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/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 6215da3ea8..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
@@ -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?: MockMark[];
};
function createParagraphNode(attrs: Record): MockNode {
@@ -39,19 +41,58 @@ function createParagraphNode(attrs: Record): MockNode {
};
}
-function makeEditor(paragraphProperties: Record): {
+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: MockMark[] = [],
+): {
editor: Editor;
setNodeMarkup: ReturnType;
+ removeMark: ReturnType;
+ addMark: ReturnType;
+ dispatch: 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 addMark = vi.fn().mockReturnThis();
const tr = {
setNodeMarkup,
+ removeMark,
+ addMark,
+ doc: {
+ nodesBetween(callbackStart: number, callbackEnd: number, callback: (node: MockNode, pos: number) => void) {
+ if (callbackStart < callbackEnd) {
+ callback(textNode, 1);
+ }
+ },
+ },
};
const doc = {
@@ -61,6 +102,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 +115,7 @@ function makeEditor(paragraphProperties: Record): {
commands: {},
} as unknown as Editor;
- return { editor, setNodeMarkup };
+ return { editor, setNodeMarkup, removeMark, addMark, dispatch: editor.dispatch as ReturnType };
}
describe('paragraphsSetIndentationWrapper', () => {
@@ -101,3 +147,74 @@ describe('paragraphsSetIndentationWrapper', () => {
expect(nextAttrs.paragraphProperties.indent).toEqual({ right: 120, hanging: 360 });
});
});
+
+describe('paragraphsSetStyleWrapper', () => {
+ 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(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 4c3e0b5a7b..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,6 +48,17 @@ import { executeDomainCommand } from './plan-wrappers.js';
// ---------------------------------------------------------------------------
const PARAGRAPH_NODE_TYPES = new Set(['paragraph', 'heading', 'listItem']);
+const TEXT_STYLE_CHARACTER_STYLE_ATTR = 'styleId';
+const DIRECT_FORMATTING_MARK_NAMES = new Set([
+ 'textStyle',
+ 'bold',
+ 'italic',
+ 'underline',
+ 'strike',
+ 'subscript',
+ 'superscript',
+ 'highlight',
+]);
// ---------------------------------------------------------------------------
// Target resolution
@@ -83,6 +94,88 @@ function noOpResult(operation: string): ParagraphMutationResult {
};
}
+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;
+ 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 || !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;
+ });
+
+ return true;
+ });
+
+ return changed;
+}
+
// ---------------------------------------------------------------------------
// Core mutation helper — transforms paragraphProperties on a resolved block
// ---------------------------------------------------------------------------
@@ -101,6 +194,9 @@ function mutateParagraphProperties(
target: ParagraphTarget,
transform: (pPr: PPr) => PPr,
options?: MutationOptions,
+ extras?: {
+ clearDirectFormatting?: boolean;
+ },
): ParagraphMutationResult {
if (options?.dryRun) return successResult(target);
@@ -116,6 +212,11 @@ function mutateParagraphProperties(
if (JSON.stringify(existing) === JSON.stringify(updated)) return false;
const tr = editor.state.tr;
+
+ if (extras?.clearDirectFormatting) {
+ clearDirectFormattingInBlock(tr, candidate.pos, node.nodeSize);
+ }
+
tr.setNodeMarkup(candidate.pos, undefined, { ...node.attrs, paragraphProperties: updated });
editor.dispatch(tr);
clearIndexCache(editor);
@@ -220,6 +321,7 @@ export function paragraphsSetStyleWrapper(
styleId: input.styleId,
}),
options,
+ { clearDirectFormatting: true },
);
}