From 96ec1a6221b8cd282e29117e6cafc979cc5696cc Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 17:53:03 -0700 Subject: [PATCH 1/3] feat(document-api): list creation and style edit commands --- .../document-api/available-operations.mdx | 9 +- .../reference/_generated-manifest.json | 18 +- .../reference/capabilities/get.mdx | 322 ++++++++++++ apps/docs/document-api/reference/index.mdx | 17 +- .../reference/lists/apply-style.mdx | 341 ++++++++++++ .../reference/lists/apply-template.mdx | 4 +- .../reference/lists/capture-template.mdx | 4 +- .../document-api/reference/lists/create.mdx | 233 +++++++-- .../reference/lists/get-style.mdx | 401 ++++++++++++++ .../document-api/reference/lists/index.mdx | 7 + .../reference/lists/restart-at.mdx | 239 +++++++++ .../reference/lists/set-level-layout.mdx | 289 +++++++++++ .../lists/set-level-number-style.mdx | 252 +++++++++ .../reference/lists/set-level-numbering.mdx | 4 +- .../reference/lists/set-level-start.mdx | 253 +++++++++ .../reference/lists/set-level-text.mdx | 251 +++++++++ apps/docs/document-engine/sdks.mdx | 34 +- .../src/contract/operation-definitions.ts | 128 ++++- .../src/contract/operation-registry.ts | 21 + packages/document-api/src/contract/schemas.ts | 228 +++++++- packages/document-api/src/index.ts | 50 ++ packages/document-api/src/invoke/invoke.ts | 9 + packages/document-api/src/lists/lists.ts | 88 ++++ .../document-api/src/lists/lists.types.ts | 127 ++++- .../helpers/list-level-formatting-helpers.js | 450 +++++++++++++++- .../contract-conformance.test.ts | 308 +++++++++++ .../assemble-adapters.ts | 20 + .../plan-engine/lists-formatting-wrappers.ts | 489 +++++++++++++++++- .../plan-engine/lists-wrappers.ts | 227 ++++++-- .../tests/lists/all-commands.ts | 211 ++++++++ .../tests/lists/style-commands-roundtrip.ts | 305 +++++++++++ 31 files changed, 5203 insertions(+), 136 deletions(-) create mode 100644 apps/docs/document-api/reference/lists/apply-style.mdx create mode 100644 apps/docs/document-api/reference/lists/get-style.mdx create mode 100644 apps/docs/document-api/reference/lists/restart-at.mdx create mode 100644 apps/docs/document-api/reference/lists/set-level-layout.mdx create mode 100644 apps/docs/document-api/reference/lists/set-level-number-style.mdx create mode 100644 apps/docs/document-api/reference/lists/set-level-start.mdx create mode 100644 apps/docs/document-api/reference/lists/set-level-text.mdx create mode 100644 tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 413706b39a..d2047a2ebd 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -33,7 +33,7 @@ Use the tables below to see what operations are available and where each one is | Hyperlinks | 6 | 0 | 6 | [Reference](/document-api/reference/hyperlinks/index) | | Images | 27 | 0 | 27 | [Reference](/document-api/reference/images/index) | | Index | 11 | 0 | 11 | [Reference](/document-api/reference/index/index) | -| Lists | 29 | 0 | 29 | [Reference](/document-api/reference/lists/index) | +| Lists | 36 | 0 | 36 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | | Paragraph Formatting | 17 | 0 | 17 | [Reference](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Reference](/document-api/reference/styles/paragraph/index) | @@ -306,6 +306,13 @@ Use the tables below to see what operations are available and where each one is | editor.doc.lists.setLevelTrailingCharacter(...) | [`lists.setLevelTrailingCharacter`](/document-api/reference/lists/set-level-trailing-character) | | editor.doc.lists.setLevelMarkerFont(...) | [`lists.setLevelMarkerFont`](/document-api/reference/lists/set-level-marker-font) | | editor.doc.lists.clearLevelOverrides(...) | [`lists.clearLevelOverrides`](/document-api/reference/lists/clear-level-overrides) | +| editor.doc.lists.getStyle(...) | [`lists.getStyle`](/document-api/reference/lists/get-style) | +| editor.doc.lists.applyStyle(...) | [`lists.applyStyle`](/document-api/reference/lists/apply-style) | +| editor.doc.lists.restartAt(...) | [`lists.restartAt`](/document-api/reference/lists/restart-at) | +| editor.doc.lists.setLevelNumberStyle(...) | [`lists.setLevelNumberStyle`](/document-api/reference/lists/set-level-number-style) | +| editor.doc.lists.setLevelText(...) | [`lists.setLevelText`](/document-api/reference/lists/set-level-text) | +| editor.doc.lists.setLevelStart(...) | [`lists.setLevelStart`](/document-api/reference/lists/set-level-start) | +| editor.doc.lists.setLevelLayout(...) | [`lists.setLevelLayout`](/document-api/reference/lists/set-level-layout) | | editor.doc.mutations.preview(...) | [`mutations.preview`](/document-api/reference/mutations/preview) | | editor.doc.mutations.apply(...) | [`mutations.apply`](/document-api/reference/mutations/apply) | | editor.doc.format.paragraph.resetDirectFormatting(...) | [`format.paragraph.resetDirectFormatting`](/document-api/reference/format/paragraph/reset-direct-formatting) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 104d6f07a8..2bb82f4d69 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -278,6 +278,7 @@ "apps/docs/document-api/reference/info.mdx", "apps/docs/document-api/reference/insert.mdx", "apps/docs/document-api/reference/lists/apply-preset.mdx", + "apps/docs/document-api/reference/lists/apply-style.mdx", "apps/docs/document-api/reference/lists/apply-template.mdx", "apps/docs/document-api/reference/lists/attach.mdx", "apps/docs/document-api/reference/lists/can-continue-previous.mdx", @@ -288,6 +289,7 @@ "apps/docs/document-api/reference/lists/convert-to-text.mdx", "apps/docs/document-api/reference/lists/create.mdx", "apps/docs/document-api/reference/lists/detach.mdx", + "apps/docs/document-api/reference/lists/get-style.mdx", "apps/docs/document-api/reference/lists/get.mdx", "apps/docs/document-api/reference/lists/indent.mdx", "apps/docs/document-api/reference/lists/index.mdx", @@ -295,14 +297,19 @@ "apps/docs/document-api/reference/lists/join.mdx", "apps/docs/document-api/reference/lists/list.mdx", "apps/docs/document-api/reference/lists/outdent.mdx", + "apps/docs/document-api/reference/lists/restart-at.mdx", "apps/docs/document-api/reference/lists/separate.mdx", "apps/docs/document-api/reference/lists/set-level-alignment.mdx", "apps/docs/document-api/reference/lists/set-level-bullet.mdx", "apps/docs/document-api/reference/lists/set-level-indents.mdx", + "apps/docs/document-api/reference/lists/set-level-layout.mdx", "apps/docs/document-api/reference/lists/set-level-marker-font.mdx", + "apps/docs/document-api/reference/lists/set-level-number-style.mdx", "apps/docs/document-api/reference/lists/set-level-numbering.mdx", "apps/docs/document-api/reference/lists/set-level-picture-bullet.mdx", "apps/docs/document-api/reference/lists/set-level-restart.mdx", + "apps/docs/document-api/reference/lists/set-level-start.mdx", + "apps/docs/document-api/reference/lists/set-level-text.mdx", "apps/docs/document-api/reference/lists/set-level-trailing-character.mdx", "apps/docs/document-api/reference/lists/set-level.mdx", "apps/docs/document-api/reference/lists/set-type.mdx", @@ -567,7 +574,14 @@ "lists.setLevelIndents", "lists.setLevelTrailingCharacter", "lists.setLevelMarkerFont", - "lists.clearLevelOverrides" + "lists.clearLevelOverrides", + "lists.getStyle", + "lists.applyStyle", + "lists.restartAt", + "lists.setLevelNumberStyle", + "lists.setLevelText", + "lists.setLevelStart", + "lists.setLevelLayout" ], "pagePath": "apps/docs/document-api/reference/lists/index.mdx", "title": "Lists" @@ -962,5 +976,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "7c98e8a222685ebb7801111b454a2e5bc6ef18b1ce4a9d7d576a5e48aa69bd5f" + "sourceHash": "455ce1c9b3cf75b22ae1ebc76bddb66f31f70f0ab46cea0f178063c87e476dbd" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index edb3ba65e4..06d2367c67 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -1547,6 +1547,11 @@ _No fields._ | `operations.lists.applyPreset.dryRun` | boolean | yes | | | `operations.lists.applyPreset.reasons` | enum[] | no | | | `operations.lists.applyPreset.tracked` | boolean | yes | | +| `operations.lists.applyStyle` | object | yes | | +| `operations.lists.applyStyle.available` | boolean | yes | | +| `operations.lists.applyStyle.dryRun` | boolean | yes | | +| `operations.lists.applyStyle.reasons` | enum[] | no | | +| `operations.lists.applyStyle.tracked` | boolean | yes | | | `operations.lists.applyTemplate` | object | yes | | | `operations.lists.applyTemplate.available` | boolean | yes | | | `operations.lists.applyTemplate.dryRun` | boolean | yes | | @@ -1602,6 +1607,11 @@ _No fields._ | `operations.lists.get.dryRun` | boolean | yes | | | `operations.lists.get.reasons` | enum[] | no | | | `operations.lists.get.tracked` | boolean | yes | | +| `operations.lists.getStyle` | object | yes | | +| `operations.lists.getStyle.available` | boolean | yes | | +| `operations.lists.getStyle.dryRun` | boolean | yes | | +| `operations.lists.getStyle.reasons` | enum[] | no | | +| `operations.lists.getStyle.tracked` | boolean | yes | | | `operations.lists.indent` | object | yes | | | `operations.lists.indent.available` | boolean | yes | | | `operations.lists.indent.dryRun` | boolean | yes | | @@ -1627,6 +1637,11 @@ _No fields._ | `operations.lists.outdent.dryRun` | boolean | yes | | | `operations.lists.outdent.reasons` | enum[] | no | | | `operations.lists.outdent.tracked` | boolean | yes | | +| `operations.lists.restartAt` | object | yes | | +| `operations.lists.restartAt.available` | boolean | yes | | +| `operations.lists.restartAt.dryRun` | boolean | yes | | +| `operations.lists.restartAt.reasons` | enum[] | no | | +| `operations.lists.restartAt.tracked` | boolean | yes | | | `operations.lists.separate` | object | yes | | | `operations.lists.separate.available` | boolean | yes | | | `operations.lists.separate.dryRun` | boolean | yes | | @@ -1652,11 +1667,21 @@ _No fields._ | `operations.lists.setLevelIndents.dryRun` | boolean | yes | | | `operations.lists.setLevelIndents.reasons` | enum[] | no | | | `operations.lists.setLevelIndents.tracked` | boolean | yes | | +| `operations.lists.setLevelLayout` | object | yes | | +| `operations.lists.setLevelLayout.available` | boolean | yes | | +| `operations.lists.setLevelLayout.dryRun` | boolean | yes | | +| `operations.lists.setLevelLayout.reasons` | enum[] | no | | +| `operations.lists.setLevelLayout.tracked` | boolean | yes | | | `operations.lists.setLevelMarkerFont` | object | yes | | | `operations.lists.setLevelMarkerFont.available` | boolean | yes | | | `operations.lists.setLevelMarkerFont.dryRun` | boolean | yes | | | `operations.lists.setLevelMarkerFont.reasons` | enum[] | no | | | `operations.lists.setLevelMarkerFont.tracked` | boolean | yes | | +| `operations.lists.setLevelNumberStyle` | object | yes | | +| `operations.lists.setLevelNumberStyle.available` | boolean | yes | | +| `operations.lists.setLevelNumberStyle.dryRun` | boolean | yes | | +| `operations.lists.setLevelNumberStyle.reasons` | enum[] | no | | +| `operations.lists.setLevelNumberStyle.tracked` | boolean | yes | | | `operations.lists.setLevelNumbering` | object | yes | | | `operations.lists.setLevelNumbering.available` | boolean | yes | | | `operations.lists.setLevelNumbering.dryRun` | boolean | yes | | @@ -1672,6 +1697,16 @@ _No fields._ | `operations.lists.setLevelRestart.dryRun` | boolean | yes | | | `operations.lists.setLevelRestart.reasons` | enum[] | no | | | `operations.lists.setLevelRestart.tracked` | boolean | yes | | +| `operations.lists.setLevelStart` | object | yes | | +| `operations.lists.setLevelStart.available` | boolean | yes | | +| `operations.lists.setLevelStart.dryRun` | boolean | yes | | +| `operations.lists.setLevelStart.reasons` | enum[] | no | | +| `operations.lists.setLevelStart.tracked` | boolean | yes | | +| `operations.lists.setLevelText` | object | yes | | +| `operations.lists.setLevelText.available` | boolean | yes | | +| `operations.lists.setLevelText.dryRun` | boolean | yes | | +| `operations.lists.setLevelText.reasons` | enum[] | no | | +| `operations.lists.setLevelText.tracked` | boolean | yes | | | `operations.lists.setLevelTrailingCharacter` | object | yes | | | `operations.lists.setLevelTrailingCharacter.available` | boolean | yes | | | `operations.lists.setLevelTrailingCharacter.dryRun` | boolean | yes | | @@ -3663,6 +3698,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "lists.applyStyle": { + "available": true, + "dryRun": true, + "tracked": false + }, "lists.applyTemplate": { "available": true, "dryRun": true, @@ -3718,6 +3758,11 @@ _No fields._ "dryRun": false, "tracked": false }, + "lists.getStyle": { + "available": true, + "dryRun": false, + "tracked": false + }, "lists.indent": { "available": true, "dryRun": true, @@ -3743,6 +3788,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "lists.restartAt": { + "available": true, + "dryRun": true, + "tracked": false + }, "lists.separate": { "available": true, "dryRun": true, @@ -3768,6 +3818,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "lists.setLevelLayout": { + "available": true, + "dryRun": true, + "tracked": false + }, "lists.setLevelMarkerFont": { "available": true, "dryRun": true, @@ -3778,6 +3833,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "lists.setLevelNumberStyle": { + "available": true, + "dryRun": true, + "tracked": false + }, "lists.setLevelPictureBullet": { "available": true, "dryRun": true, @@ -3788,6 +3848,16 @@ _No fields._ "dryRun": true, "tracked": false }, + "lists.setLevelStart": { + "available": true, + "dryRun": true, + "tracked": false + }, + "lists.setLevelText": { + "available": true, + "dryRun": true, + "tracked": false + }, "lists.setLevelTrailingCharacter": { "available": true, "dryRun": true, @@ -14811,6 +14881,41 @@ _No fields._ ], "type": "object" }, + "lists.applyStyle": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.applyTemplate": { "additionalProperties": false, "properties": { @@ -15196,6 +15301,41 @@ _No fields._ ], "type": "object" }, + "lists.getStyle": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.indent": { "additionalProperties": false, "properties": { @@ -15371,6 +15511,41 @@ _No fields._ ], "type": "object" }, + "lists.restartAt": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.separate": { "additionalProperties": false, "properties": { @@ -15546,6 +15721,41 @@ _No fields._ ], "type": "object" }, + "lists.setLevelLayout": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.setLevelMarkerFont": { "additionalProperties": false, "properties": { @@ -15616,6 +15826,41 @@ _No fields._ ], "type": "object" }, + "lists.setLevelNumberStyle": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.setLevelPictureBullet": { "additionalProperties": false, "properties": { @@ -15686,6 +15931,76 @@ _No fields._ ], "type": "object" }, + "lists.setLevelStart": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.setLevelText": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.setLevelTrailingCharacter": { "additionalProperties": false, "properties": { @@ -18793,6 +19108,13 @@ _No fields._ "lists.setLevelTrailingCharacter", "lists.setLevelMarkerFont", "lists.clearLevelOverrides", + "lists.getStyle", + "lists.applyStyle", + "lists.restartAt", + "lists.setLevelNumberStyle", + "lists.setLevelText", + "lists.setLevelStart", + "lists.setLevelLayout", "comments.create", "comments.patch", "comments.delete", diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index a45e8c27e9..b9d86a11c0 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -27,7 +27,7 @@ Document API is currently alpha and subject to breaking changes. | Sections | 18 | 0 | 18 | [Open](/document-api/reference/sections/index) | | Format | 44 | 1 | 45 | [Open](/document-api/reference/format/index) | | Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | -| Lists | 29 | 0 | 29 | [Open](/document-api/reference/lists/index) | +| Lists | 36 | 0 | 36 | [Open](/document-api/reference/lists/index) | | Comments | 5 | 0 | 5 | [Open](/document-api/reference/comments/index) | | Track Changes | 3 | 0 | 3 | [Open](/document-api/reference/track-changes/index) | | Query | 1 | 0 | 1 | [Open](/document-api/reference/query/index) | @@ -185,7 +185,7 @@ The tables below are grouped by namespace. | lists.list | editor.doc.lists.list(...) | List all list nodes in the document, optionally filtered by scope. | | lists.get | editor.doc.lists.get(...) | Retrieve a specific list node by target. | | lists.insert | editor.doc.lists.insert(...) | Insert a new list item before or after an existing list item. The new item inherits the target list context. | -| lists.create | editor.doc.lists.create(...) | Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. | +| lists.create | editor.doc.lists.create(...) | Create a new list from one or more paragraphs. Supports optional preset or style for new sequences. When sequence.mode is "continuePrevious", preset and style are not allowed — the new items inherit formatting from the previous sequence. | | lists.attach | editor.doc.lists.attach(...) | Convert non-list paragraphs to list items under an existing list sequence. | | lists.detach | editor.doc.lists.detach(...) | Remove numbering properties from list items, converting them to plain paragraphs. | | lists.indent | editor.doc.lists.indent(...) | Increase the indentation level of a list item. | @@ -199,11 +199,11 @@ The tables below are grouped by namespace. | lists.canContinuePrevious | editor.doc.lists.canContinuePrevious(...) | Check whether the target sequence can continue numbering from a previous compatible sequence. | | lists.setLevelRestart | editor.doc.lists.setLevelRestart(...) | Set the restart behavior for a specific list level. | | lists.convertToText | editor.doc.lists.convertToText(...) | Convert list items to plain paragraphs, optionally prepending the rendered marker text. | -| lists.applyTemplate | editor.doc.lists.applyTemplate(...) | Apply a captured ListTemplate to the target list, optionally filtered to specific levels. | +| lists.applyTemplate | editor.doc.lists.applyTemplate(...) | Advanced alias for lists.applyStyle. Apply a captured ListTemplate to the target list (abstract-scoped, no clone-on-write). | | lists.applyPreset | editor.doc.lists.applyPreset(...) | Apply a built-in list formatting preset to the target list. | | lists.setType | editor.doc.lists.setType(...) | Convert a list to ordered or bullet and merge adjacent compatible sequences to preserve continuous numbering. | -| lists.captureTemplate | editor.doc.lists.captureTemplate(...) | Capture the formatting of a list as a reusable ListTemplate. | -| lists.setLevelNumbering | editor.doc.lists.setLevelNumbering(...) | Set the numbering format, pattern, and optional start value for a specific list level. | +| lists.captureTemplate | editor.doc.lists.captureTemplate(...) | Advanced alias for lists.getStyle. Capture list formatting from the abstract definition only (does not merge lvlOverride formatting). | +| lists.setLevelNumbering | editor.doc.lists.setLevelNumbering(...) | Advanced alias for lists.setLevelNumberStyle/setLevelText/setLevelStart. Set format, pattern, and start in one call (abstract-scoped, no clone-on-write). | | lists.setLevelBullet | editor.doc.lists.setLevelBullet(...) | Set the bullet marker text for a specific list level. | | lists.setLevelPictureBullet | editor.doc.lists.setLevelPictureBullet(...) | Set a picture bullet for a specific list level by its OOXML lvlPicBulletId. | | lists.setLevelAlignment | editor.doc.lists.setLevelAlignment(...) | Set the marker alignment (left, center, right) for a specific list level. | @@ -211,6 +211,13 @@ The tables below are grouped by namespace. | lists.setLevelTrailingCharacter | editor.doc.lists.setLevelTrailingCharacter(...) | Set the trailing character (tab, space, nothing) after the marker for a specific list level. | | lists.setLevelMarkerFont | editor.doc.lists.setLevelMarkerFont(...) | Set the font family used for the marker character at a specific list level. | | lists.clearLevelOverrides | editor.doc.lists.clearLevelOverrides(...) | Remove instance-level overrides for a specific list level, restoring abstract definition values. | +| lists.getStyle | editor.doc.lists.getStyle(...) | Read the effective reusable style of a list, including instance-level overrides. Returns a ListStyle that can be applied to other lists via lists.applyStyle. | +| lists.applyStyle | editor.doc.lists.applyStyle(...) | Apply a reusable list style to the target list. Sequence-local: if the abstract definition is shared with other lists, it is cloned first to avoid affecting them. | +| lists.restartAt | editor.doc.lists.restartAt(...) | Restart numbering at the target list item with a specific value. If the item is mid-sequence, it is separated first. | +| lists.setLevelNumberStyle | editor.doc.lists.setLevelNumberStyle(...) | Set the numbering style (e.g. decimal, lowerLetter, upperRoman) for a specific list level. Rejects "bullet" — use setLevelBullet instead. Sequence-local: clones shared definitions. | +| lists.setLevelText | editor.doc.lists.setLevelText(...) | Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions. | +| lists.setLevelStart | editor.doc.lists.setLevelStart(...) | Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions. | +| lists.setLevelLayout | editor.doc.lists.setLevelLayout(...) | Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates — omitted fields are left unchanged. Sequence-local: clones shared definitions. | #### Comments diff --git a/apps/docs/document-api/reference/lists/apply-style.mdx b/apps/docs/document-api/reference/lists/apply-style.mdx new file mode 100644 index 0000000000..b56c8a0ae8 --- /dev/null +++ b/apps/docs/document-api/reference/lists/apply-style.mdx @@ -0,0 +1,341 @@ +--- +title: lists.applyStyle +sidebarTitle: lists.applyStyle +description: "Apply a reusable list style to the target list. Sequence-local: if the abstract definition is shared with other lists, it is cloned first to avoid affecting them." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Apply a reusable list style to the target list. Sequence-local: if the abstract definition is shared with other lists, it is cloned first to avoid affecting them. + +- Operation ID: `lists.applyStyle` +- API member path: `editor.doc.lists.applyStyle(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt; reports NO_OP if all levels already match. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `levels` | integer[] | no | | +| `style` | object | yes | | +| `style.levels` | object[] | yes | | +| `style.version` | `1` | yes | Constant: `1` | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "levels": [ + 1 + ], + "style": { + "levels": [ + { + "level": 1, + "lvlText": "example", + "numFmt": "example" + } + ], + "version": 1 + }, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"INVALID_INPUT"`, `"LEVEL_OUT_OF_RANGE"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `INVALID_INPUT` +- `LEVEL_OUT_OF_RANGE` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "style": { + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "additionalProperties": false, + "properties": { + "alignment": { + "enum": [ + "left", + "center", + "right" + ] + }, + "indents": { + "additionalProperties": false, + "properties": { + "firstLine": { + "type": "integer" + }, + "hanging": { + "type": "integer" + }, + "left": { + "type": "integer" + } + }, + "type": "object" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "lvlText": { + "type": "string" + }, + "markerFont": { + "type": "string" + }, + "numFmt": { + "type": "string" + }, + "pictureBulletId": { + "type": "integer" + }, + "start": { + "type": "integer" + }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "levels" + ], + "type": "object" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "style" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/apply-template.mdx b/apps/docs/document-api/reference/lists/apply-template.mdx index c34187c1ab..f938d5b9ed 100644 --- a/apps/docs/document-api/reference/lists/apply-template.mdx +++ b/apps/docs/document-api/reference/lists/apply-template.mdx @@ -1,7 +1,7 @@ --- title: lists.applyTemplate sidebarTitle: lists.applyTemplate -description: Apply a captured ListTemplate to the target list, optionally filtered to specific levels. +description: Advanced alias for lists.applyStyle. Apply a captured ListTemplate to the target list (abstract-scoped, no clone-on-write). --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Apply a captured ListTemplate to the target list, optionally filter ## Summary -Apply a captured ListTemplate to the target list, optionally filtered to specific levels. +Advanced alias for lists.applyStyle. Apply a captured ListTemplate to the target list (abstract-scoped, no clone-on-write). - Operation ID: `lists.applyTemplate` - API member path: `editor.doc.lists.applyTemplate(...)` diff --git a/apps/docs/document-api/reference/lists/capture-template.mdx b/apps/docs/document-api/reference/lists/capture-template.mdx index 4b38b91a21..58a6cb35ef 100644 --- a/apps/docs/document-api/reference/lists/capture-template.mdx +++ b/apps/docs/document-api/reference/lists/capture-template.mdx @@ -1,7 +1,7 @@ --- title: lists.captureTemplate sidebarTitle: lists.captureTemplate -description: Capture the formatting of a list as a reusable ListTemplate. +description: Advanced alias for lists.getStyle. Capture list formatting from the abstract definition only (does not merge lvlOverride formatting). --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Capture the formatting of a list as a reusable ListTemplate. ## Summary -Capture the formatting of a list as a reusable ListTemplate. +Advanced alias for lists.getStyle. Capture list formatting from the abstract definition only (does not merge lvlOverride formatting). - Operation ID: `lists.captureTemplate` - API member path: `editor.doc.lists.captureTemplate(...)` diff --git a/apps/docs/document-api/reference/lists/create.mdx b/apps/docs/document-api/reference/lists/create.mdx index ed7b2d7d5a..78a4363832 100644 --- a/apps/docs/document-api/reference/lists/create.mdx +++ b/apps/docs/document-api/reference/lists/create.mdx @@ -1,7 +1,7 @@ --- title: lists.create sidebarTitle: lists.create -description: Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. +description: "Create a new list from one or more paragraphs. Supports optional preset or style for new sequences. When sequence.mode is \"continuePrevious\", preset and style are not allowed — the new items inherit formatting from the previous sequence." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Create a new list from one or more paragraphs, or convert existing ## Summary -Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. +Create a new list from one or more paragraphs. Supports optional preset or style for new sequences. When sequence.mode is "continuePrevious", preset and style are not allowed — the new items inherit formatting from the previous sequence. - Operation ID: `lists.create` - API member path: `editor.doc.lists.create(...)` @@ -26,16 +26,7 @@ Returns a ListsCreateResult with the new listId and the first item address. ## Input fields -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `at` | BlockAddress | no | BlockAddress | -| `at.kind` | `"block"` | no | Constant: `"block"` | -| `at.nodeId` | string | no | | -| `at.nodeType` | `"paragraph"` | no | Constant: `"paragraph"` | -| `kind` | enum | yes | `"ordered"`, `"bullet"` | -| `level` | integer | no | | -| `mode` | enum | yes | `"empty"`, `"fromParagraphs"` | -| `target` | BlockAddressOrRange | no | BlockAddressOrRange | +_No fields._ ### Example request @@ -46,7 +37,6 @@ Returns a ListsCreateResult with the new listId and the first item address. "nodeId": "node-def456", "nodeType": "paragraph" }, - "kind": "ordered", "mode": "empty", "target": { "kind": "block", @@ -74,7 +64,7 @@ Returns a ListsCreateResult with the new listId and the first item address. | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"LEVEL_OUT_OF_RANGE"` | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"LEVEL_OUT_OF_RANGE"`, `"INVALID_INPUT"`, `"NO_COMPATIBLE_PREVIOUS"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -98,11 +88,14 @@ Returns a ListsCreateResult with the new listId and the first item address. - `TARGET_NOT_FOUND` - `CAPABILITY_UNAVAILABLE` - `INVALID_TARGET` +- `INVALID_INPUT` ## Non-applied failure codes - `INVALID_TARGET` - `LEVEL_OUT_OF_RANGE` +- `INVALID_INPUT` +- `NO_COMPATIBLE_PREVIOUS` ## Raw schemas @@ -110,20 +103,64 @@ Returns a ListsCreateResult with the new listId and the first item address. ```json { "additionalProperties": false, - "else": { - "required": [ - "mode", - "kind", - "target" - ] - }, - "if": { - "properties": { - "mode": { - "const": "empty" + "allOf": [ + { + "else": { + "required": [ + "mode", + "target" + ] + }, + "if": { + "properties": { + "mode": { + "const": "empty" + } + } + }, + "then": { + "required": [ + "mode", + "at" + ] + } + }, + { + "if": { + "properties": { + "sequence": { + "properties": { + "mode": { + "const": "continuePrevious" + } + }, + "required": [ + "mode" + ] + } + }, + "required": [ + "sequence" + ] + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "preset" + ] + }, + { + "required": [ + "style" + ] + } + ] + } } } - }, + ], "properties": { "at": { "$ref": "#/$defs/BlockAddress" @@ -145,21 +182,139 @@ Returns a ListsCreateResult with the new listId and the first item address. "fromParagraphs" ] }, + "preset": { + "enum": [ + "decimal", + "decimalParenthesis", + "lowerLetter", + "upperLetter", + "lowerRoman", + "upperRoman", + "disc", + "circle", + "square", + "dash" + ] + }, + "sequence": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "mode": { + "const": "new" + }, + "startAt": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "mode": { + "const": "continuePrevious" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + ] + }, + "style": { + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "additionalProperties": false, + "properties": { + "alignment": { + "enum": [ + "left", + "center", + "right" + ] + }, + "indents": { + "additionalProperties": false, + "properties": { + "firstLine": { + "type": "integer" + }, + "hanging": { + "type": "integer" + }, + "left": { + "type": "integer" + } + }, + "type": "object" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "lvlText": { + "type": "string" + }, + "markerFont": { + "type": "string" + }, + "numFmt": { + "type": "string" + }, + "pictureBulletId": { + "type": "integer" + }, + "start": { + "type": "integer" + }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "levels" + ], + "type": "object" + }, "target": { "$ref": "#/$defs/BlockAddressOrRange" } }, "required": [ - "mode", - "kind" + "mode" ], - "then": { - "required": [ - "mode", - "kind", - "at" - ] - }, "type": "object" } ``` @@ -198,7 +353,9 @@ Returns a ListsCreateResult with the new listId and the first item address. "code": { "enum": [ "INVALID_TARGET", - "LEVEL_OUT_OF_RANGE" + "LEVEL_OUT_OF_RANGE", + "INVALID_INPUT", + "NO_COMPATIBLE_PREVIOUS" ] }, "details": {}, @@ -263,7 +420,9 @@ Returns a ListsCreateResult with the new listId and the first item address. "code": { "enum": [ "INVALID_TARGET", - "LEVEL_OUT_OF_RANGE" + "LEVEL_OUT_OF_RANGE", + "INVALID_INPUT", + "NO_COMPATIBLE_PREVIOUS" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/lists/get-style.mdx b/apps/docs/document-api/reference/lists/get-style.mdx new file mode 100644 index 0000000000..997ee11c69 --- /dev/null +++ b/apps/docs/document-api/reference/lists/get-style.mdx @@ -0,0 +1,401 @@ +--- +title: lists.getStyle +sidebarTitle: lists.getStyle +description: Read the effective reusable style of a list, including instance-level overrides. Returns a ListStyle that can be applied to other lists via lists.applyStyle. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Read the effective reusable style of a list, including instance-level overrides. Returns a ListStyle that can be applied to other lists via lists.applyStyle. + +- Operation ID: `lists.getStyle` +- API member path: `editor.doc.lists.getStyle(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsGetStyleResult containing the captured style. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `levels` | integer[] | no | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "levels": [ + 1 + ], + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `style` | object | yes | | +| `style.levels` | object[] | yes | | +| `style.version` | `1` | yes | Constant: `1` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"INVALID_INPUT"`, `"LEVEL_OUT_OF_RANGE"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "style": { + "levels": [ + { + "level": 1, + "lvlText": "example", + "numFmt": "example" + } + ], + "version": 1 + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `INVALID_INPUT` +- `LEVEL_OUT_OF_RANGE` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "style": { + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "additionalProperties": false, + "properties": { + "alignment": { + "enum": [ + "left", + "center", + "right" + ] + }, + "indents": { + "additionalProperties": false, + "properties": { + "firstLine": { + "type": "integer" + }, + "hanging": { + "type": "integer" + }, + "left": { + "type": "integer" + } + }, + "type": "object" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "lvlText": { + "type": "string" + }, + "markerFont": { + "type": "string" + }, + "numFmt": { + "type": "string" + }, + "pictureBulletId": { + "type": "integer" + }, + "start": { + "type": "integer" + }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "levels" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "style" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "style": { + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "additionalProperties": false, + "properties": { + "alignment": { + "enum": [ + "left", + "center", + "right" + ] + }, + "indents": { + "additionalProperties": false, + "properties": { + "firstLine": { + "type": "integer" + }, + "hanging": { + "type": "integer" + }, + "left": { + "type": "integer" + } + }, + "type": "object" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "lvlText": { + "type": "string" + }, + "markerFont": { + "type": "string" + }, + "numFmt": { + "type": "string" + }, + "pictureBulletId": { + "type": "integer" + }, + "start": { + "type": "integer" + }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "levels" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "style" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/index.mdx b/apps/docs/document-api/reference/lists/index.mdx index c6e7bd162d..9cb748e0f7 100644 --- a/apps/docs/document-api/reference/lists/index.mdx +++ b/apps/docs/document-api/reference/lists/index.mdx @@ -43,4 +43,11 @@ List inspection and list mutations. | lists.setLevelTrailingCharacter | `lists.setLevelTrailingCharacter` | Yes | `conditional` | No | Yes | | lists.setLevelMarkerFont | `lists.setLevelMarkerFont` | Yes | `conditional` | No | Yes | | lists.clearLevelOverrides | `lists.clearLevelOverrides` | Yes | `conditional` | No | Yes | +| lists.getStyle | `lists.getStyle` | No | `idempotent` | No | No | +| lists.applyStyle | `lists.applyStyle` | Yes | `conditional` | No | Yes | +| lists.restartAt | `lists.restartAt` | Yes | `non-idempotent` | No | Yes | +| lists.setLevelNumberStyle | `lists.setLevelNumberStyle` | Yes | `conditional` | No | Yes | +| lists.setLevelText | `lists.setLevelText` | Yes | `conditional` | No | Yes | +| lists.setLevelStart | `lists.setLevelStart` | Yes | `conditional` | No | Yes | +| lists.setLevelLayout | `lists.setLevelLayout` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/lists/restart-at.mdx b/apps/docs/document-api/reference/lists/restart-at.mdx new file mode 100644 index 0000000000..05d57e4748 --- /dev/null +++ b/apps/docs/document-api/reference/lists/restart-at.mdx @@ -0,0 +1,239 @@ +--- +title: lists.restartAt +sidebarTitle: lists.restartAt +description: Restart numbering at the target list item with a specific value. If the item is mid-sequence, it is separated first. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Restart numbering at the target list item with a specific value. If the item is mid-sequence, it is separated first. + +- Operation ID: `lists.restartAt` +- API member path: `editor.doc.lists.restartAt(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `startAt` | integer | yes | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "startAt": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"INVALID_INPUT"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "startAt": { + "minimum": 1, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "startAt" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/set-level-layout.mdx b/apps/docs/document-api/reference/lists/set-level-layout.mdx new file mode 100644 index 0000000000..c4a2c9877b --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-layout.mdx @@ -0,0 +1,289 @@ +--- +title: lists.setLevelLayout +sidebarTitle: lists.setLevelLayout +description: "Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates — omitted fields are left unchanged. Sequence-local: clones shared definitions." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates — omitted fields are left unchanged. Sequence-local: clones shared definitions. + +- Operation ID: `lists.setLevelLayout` +- API member path: `editor.doc.lists.setLevelLayout(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt; reports NO_OP if all values already match. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `layout` | object | yes | | +| `layout.alignedAt` | integer | no | | +| `layout.alignment` | enum | no | `"left"`, `"center"`, `"right"` | +| `layout.followCharacter` | enum | no | `"tab"`, `"space"`, `"nothing"` | +| `layout.tabStopAt` | any | no | | +| `layout.textIndentAt` | integer | no | | +| `level` | integer | yes | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "layout": { + "alignedAt": 1, + "alignment": "left" + }, + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"INVALID_INPUT"`, `"LEVEL_OUT_OF_RANGE"`, `"LEVEL_NOT_FOUND"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `INVALID_INPUT` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "alignedAt": { + "type": "integer" + }, + "alignment": { + "enum": [ + "left", + "center", + "right" + ] + }, + "followCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + }, + "textIndentAt": { + "type": "integer" + } + }, + "type": "object" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "layout" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/set-level-number-style.mdx b/apps/docs/document-api/reference/lists/set-level-number-style.mdx new file mode 100644 index 0000000000..4154303637 --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-number-style.mdx @@ -0,0 +1,252 @@ +--- +title: lists.setLevelNumberStyle +sidebarTitle: lists.setLevelNumberStyle +description: "Set the numbering style (e.g. decimal, lowerLetter, upperRoman) for a specific list level. Rejects \"bullet\" — use setLevelBullet instead. Sequence-local: clones shared definitions." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the numbering style (e.g. decimal, lowerLetter, upperRoman) for a specific list level. Rejects "bullet" — use setLevelBullet instead. Sequence-local: clones shared definitions. + +- Operation ID: `lists.setLevelNumberStyle` +- API member path: `editor.doc.lists.setLevelNumberStyle(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt; reports NO_OP if the value already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `numberStyle` | string | yes | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "level": 1, + "numberStyle": "example", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"INVALID_INPUT"`, `"LEVEL_OUT_OF_RANGE"`, `"LEVEL_NOT_FOUND"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `INVALID_INPUT` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "numberStyle": { + "type": "string" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "numberStyle" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/set-level-numbering.mdx b/apps/docs/document-api/reference/lists/set-level-numbering.mdx index 16c6acebe2..3cec858d67 100644 --- a/apps/docs/document-api/reference/lists/set-level-numbering.mdx +++ b/apps/docs/document-api/reference/lists/set-level-numbering.mdx @@ -1,7 +1,7 @@ --- title: lists.setLevelNumbering sidebarTitle: lists.setLevelNumbering -description: Set the numbering format, pattern, and optional start value for a specific list level. +description: Advanced alias for lists.setLevelNumberStyle/setLevelText/setLevelStart. Set format, pattern, and start in one call (abstract-scoped, no clone-on-write). --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Set the numbering format, pattern, and optional start value for a s ## Summary -Set the numbering format, pattern, and optional start value for a specific list level. +Advanced alias for lists.setLevelNumberStyle/setLevelText/setLevelStart. Set format, pattern, and start in one call (abstract-scoped, no clone-on-write). - Operation ID: `lists.setLevelNumbering` - API member path: `editor.doc.lists.setLevelNumbering(...)` diff --git a/apps/docs/document-api/reference/lists/set-level-start.mdx b/apps/docs/document-api/reference/lists/set-level-start.mdx new file mode 100644 index 0000000000..7af437ec9a --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-start.mdx @@ -0,0 +1,253 @@ +--- +title: lists.setLevelStart +sidebarTitle: lists.setLevelStart +description: "Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions. + +- Operation ID: `lists.setLevelStart` +- API member path: `editor.doc.lists.setLevelStart(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt; reports NO_OP if the value already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `startAt` | integer | yes | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "level": 1, + "startAt": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"INVALID_INPUT"`, `"LEVEL_OUT_OF_RANGE"`, `"LEVEL_NOT_FOUND"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `INVALID_INPUT` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "startAt": { + "minimum": 1, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "startAt" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/set-level-text.mdx b/apps/docs/document-api/reference/lists/set-level-text.mdx new file mode 100644 index 0000000000..08c01a21fc --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-text.mdx @@ -0,0 +1,251 @@ +--- +title: lists.setLevelText +sidebarTitle: lists.setLevelText +description: "Set the level text pattern (e.g. \"%1.\", \"(%1)\") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions. + +- Operation ID: `lists.setLevelText` +- API member path: `editor.doc.lists.setLevelText(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt; reports NO_OP if the value already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `text` | string | yes | | + +### Example request + +```json +{ + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "text": "Hello, world." +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"INVALID_INPUT"`, `"LEVEL_OUT_OF_RANGE"`, `"LEVEL_NOT_FOUND"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `INVALID_INPUT` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "level", + "text" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "INVALID_INPUT", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 4c4f776fcb..e59d84eeee 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -380,7 +380,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | | `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | | `doc.markdownToFragment` | `markdown-to-fragment` | Convert a Markdown string into an SDM/1 structural fragment. | -| `doc.info` | `info` | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. | +| `doc.info` | `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. | | `doc.clearContent` | `clear-content` | Clear all document body content, leaving a single empty paragraph. | | `doc.insert` | `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. | | `doc.replace` | `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. | @@ -641,7 +641,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.list` | `lists list` | List all list nodes in the document, optionally filtered by scope. | | `doc.lists.get` | `lists get` | Retrieve a specific list node by target. | | `doc.lists.insert` | `lists insert` | Insert a new list item before or after an existing list item. The new item inherits the target list context. | -| `doc.lists.create` | `lists create` | Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. | +| `doc.lists.create` | `lists create` | Create a new list from one or more paragraphs. Supports optional preset or style for new sequences. When sequence.mode is "continuePrevious", preset and style are not allowed — the new items inherit formatting from the previous sequence. | | `doc.lists.attach` | `lists attach` | Convert non-list paragraphs to list items under an existing list sequence. | | `doc.lists.detach` | `lists detach` | Remove numbering properties from list items, converting them to plain paragraphs. | | `doc.lists.indent` | `lists indent` | Increase the indentation level of a list item. | @@ -655,11 +655,11 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.canContinuePrevious` | `lists can-continue-previous` | Check whether the target sequence can continue numbering from a previous compatible sequence. | | `doc.lists.setLevelRestart` | `lists set-level-restart` | Set the restart behavior for a specific list level. | | `doc.lists.convertToText` | `lists convert-to-text` | Convert list items to plain paragraphs, optionally prepending the rendered marker text. | -| `doc.lists.applyTemplate` | `lists apply-template` | Apply a captured ListTemplate to the target list, optionally filtered to specific levels. | +| `doc.lists.applyTemplate` | `lists apply-template` | Advanced alias for lists.applyStyle. Apply a captured ListTemplate to the target list (abstract-scoped, no clone-on-write). | | `doc.lists.applyPreset` | `lists apply-preset` | Apply a built-in list formatting preset to the target list. | | `doc.lists.setType` | `lists set-type` | Convert a list to ordered or bullet and merge adjacent compatible sequences to preserve continuous numbering. | -| `doc.lists.captureTemplate` | `lists capture-template` | Capture the formatting of a list as a reusable ListTemplate. | -| `doc.lists.setLevelNumbering` | `lists set-level-numbering` | Set the numbering format, pattern, and optional start value for a specific list level. | +| `doc.lists.captureTemplate` | `lists capture-template` | Advanced alias for lists.getStyle. Capture list formatting from the abstract definition only (does not merge lvlOverride formatting). | +| `doc.lists.setLevelNumbering` | `lists set-level-numbering` | Advanced alias for lists.setLevelNumberStyle/setLevelText/setLevelStart. Set format, pattern, and start in one call (abstract-scoped, no clone-on-write). | | `doc.lists.setLevelBullet` | `lists set-level-bullet` | Set the bullet marker text for a specific list level. | | `doc.lists.setLevelPictureBullet` | `lists set-level-picture-bullet` | Set a picture bullet for a specific list level by its OOXML lvlPicBulletId. | | `doc.lists.setLevelAlignment` | `lists set-level-alignment` | Set the marker alignment (left, center, right) for a specific list level. | @@ -667,6 +667,13 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.setLevelTrailingCharacter` | `lists set-level-trailing-character` | Set the trailing character (tab, space, nothing) after the marker for a specific list level. | | `doc.lists.setLevelMarkerFont` | `lists set-level-marker-font` | Set the font family used for the marker character at a specific list level. | | `doc.lists.clearLevelOverrides` | `lists clear-level-overrides` | Remove instance-level overrides for a specific list level, restoring abstract definition values. | +| `doc.lists.getStyle` | `lists get-style` | Read the effective reusable style of a list, including instance-level overrides. Returns a ListStyle that can be applied to other lists via lists.applyStyle. | +| `doc.lists.applyStyle` | `lists apply-style` | Apply a reusable list style to the target list. Sequence-local: if the abstract definition is shared with other lists, it is cloned first to avoid affecting them. | +| `doc.lists.restartAt` | `lists restart-at` | Restart numbering at the target list item with a specific value. If the item is mid-sequence, it is separated first. | +| `doc.lists.setLevelNumberStyle` | `lists set-level-number-style` | Set the numbering style (e.g. decimal, lowerLetter, upperRoman) for a specific list level. Rejects "bullet" — use setLevelBullet instead. Sequence-local: clones shared definitions. | +| `doc.lists.setLevelText` | `lists set-level-text` | Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions. | +| `doc.lists.setLevelStart` | `lists set-level-start` | Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions. | +| `doc.lists.setLevelLayout` | `lists set-level-layout` | Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates — omitted fields are left unchanged. Sequence-local: clones shared definitions. | #### Tables @@ -818,7 +825,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | | `doc.get_html` | `get-html` | Extract the document content as an HTML string. | | `doc.markdown_to_fragment` | `markdown-to-fragment` | Convert a Markdown string into an SDM/1 structural fragment. | -| `doc.info` | `info` | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. | +| `doc.info` | `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. | | `doc.clear_content` | `clear-content` | Clear all document body content, leaving a single empty paragraph. | | `doc.insert` | `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. | | `doc.replace` | `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. | @@ -1079,7 +1086,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.list` | `lists list` | List all list nodes in the document, optionally filtered by scope. | | `doc.lists.get` | `lists get` | Retrieve a specific list node by target. | | `doc.lists.insert` | `lists insert` | Insert a new list item before or after an existing list item. The new item inherits the target list context. | -| `doc.lists.create` | `lists create` | Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. | +| `doc.lists.create` | `lists create` | Create a new list from one or more paragraphs. Supports optional preset or style for new sequences. When sequence.mode is "continuePrevious", preset and style are not allowed — the new items inherit formatting from the previous sequence. | | `doc.lists.attach` | `lists attach` | Convert non-list paragraphs to list items under an existing list sequence. | | `doc.lists.detach` | `lists detach` | Remove numbering properties from list items, converting them to plain paragraphs. | | `doc.lists.indent` | `lists indent` | Increase the indentation level of a list item. | @@ -1093,11 +1100,11 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.can_continue_previous` | `lists can-continue-previous` | Check whether the target sequence can continue numbering from a previous compatible sequence. | | `doc.lists.set_level_restart` | `lists set-level-restart` | Set the restart behavior for a specific list level. | | `doc.lists.convert_to_text` | `lists convert-to-text` | Convert list items to plain paragraphs, optionally prepending the rendered marker text. | -| `doc.lists.apply_template` | `lists apply-template` | Apply a captured ListTemplate to the target list, optionally filtered to specific levels. | +| `doc.lists.apply_template` | `lists apply-template` | Advanced alias for lists.applyStyle. Apply a captured ListTemplate to the target list (abstract-scoped, no clone-on-write). | | `doc.lists.apply_preset` | `lists apply-preset` | Apply a built-in list formatting preset to the target list. | | `doc.lists.set_type` | `lists set-type` | Convert a list to ordered or bullet and merge adjacent compatible sequences to preserve continuous numbering. | -| `doc.lists.capture_template` | `lists capture-template` | Capture the formatting of a list as a reusable ListTemplate. | -| `doc.lists.set_level_numbering` | `lists set-level-numbering` | Set the numbering format, pattern, and optional start value for a specific list level. | +| `doc.lists.capture_template` | `lists capture-template` | Advanced alias for lists.getStyle. Capture list formatting from the abstract definition only (does not merge lvlOverride formatting). | +| `doc.lists.set_level_numbering` | `lists set-level-numbering` | Advanced alias for lists.setLevelNumberStyle/setLevelText/setLevelStart. Set format, pattern, and start in one call (abstract-scoped, no clone-on-write). | | `doc.lists.set_level_bullet` | `lists set-level-bullet` | Set the bullet marker text for a specific list level. | | `doc.lists.set_level_picture_bullet` | `lists set-level-picture-bullet` | Set a picture bullet for a specific list level by its OOXML lvlPicBulletId. | | `doc.lists.set_level_alignment` | `lists set-level-alignment` | Set the marker alignment (left, center, right) for a specific list level. | @@ -1105,6 +1112,13 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.set_level_trailing_character` | `lists set-level-trailing-character` | Set the trailing character (tab, space, nothing) after the marker for a specific list level. | | `doc.lists.set_level_marker_font` | `lists set-level-marker-font` | Set the font family used for the marker character at a specific list level. | | `doc.lists.clear_level_overrides` | `lists clear-level-overrides` | Remove instance-level overrides for a specific list level, restoring abstract definition values. | +| `doc.lists.get_style` | `lists get-style` | Read the effective reusable style of a list, including instance-level overrides. Returns a ListStyle that can be applied to other lists via lists.applyStyle. | +| `doc.lists.apply_style` | `lists apply-style` | Apply a reusable list style to the target list. Sequence-local: if the abstract definition is shared with other lists, it is cloned first to avoid affecting them. | +| `doc.lists.restart_at` | `lists restart-at` | Restart numbering at the target list item with a specific value. If the item is mid-sequence, it is separated first. | +| `doc.lists.set_level_number_style` | `lists set-level-number-style` | Set the numbering style (e.g. decimal, lowerLetter, upperRoman) for a specific list level. Rejects "bullet" — use setLevelBullet instead. Sequence-local: clones shared definitions. | +| `doc.lists.set_level_text` | `lists set-level-text` | Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions. | +| `doc.lists.set_level_start` | `lists set-level-start` | Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions. | +| `doc.lists.set_level_layout` | `lists set-level-layout` | Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates — omitted fields are left unchanged. Sequence-local: clones shared definitions. | #### Tables diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 1f59e228b0..5abf184735 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -1305,15 +1305,16 @@ export const OPERATION_DEFINITIONS = { }, 'lists.create': { memberPath: 'lists.create', - description: 'Create a new list from one or more paragraphs, or convert existing paragraphs into a new list.', + description: + 'Create a new list from one or more paragraphs. Supports optional preset or style for new sequences. When sequence.mode is "continuePrevious", preset and style are not allowed — the new items inherit formatting from the previous sequence.', expectedResult: 'Returns a ListsCreateResult with the new listId and the first item address.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'non-idempotent', supportsDryRun: true, supportsTrackedMode: false, - possibleFailureCodes: ['INVALID_TARGET', 'LEVEL_OUT_OF_RANGE'], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + possibleFailureCodes: ['INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'INVALID_INPUT', 'NO_COMPATIBLE_PREVIOUS'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], }), referenceDocPath: 'lists/create.mdx', referenceGroup: 'lists', @@ -1528,7 +1529,8 @@ export const OPERATION_DEFINITIONS = { // SD-1973 — List formatting and templates 'lists.applyTemplate': { memberPath: 'lists.applyTemplate', - description: 'Apply a captured ListTemplate to the target list, optionally filtered to specific levels.', + description: + 'Advanced alias for lists.applyStyle. Apply a captured ListTemplate to the target list (abstract-scoped, no clone-on-write).', expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if all levels already match.', requiresDocumentContext: true, metadata: mutationOperation({ @@ -1577,7 +1579,8 @@ export const OPERATION_DEFINITIONS = { }, 'lists.captureTemplate': { memberPath: 'lists.captureTemplate', - description: 'Capture the formatting of a list as a reusable ListTemplate.', + description: + 'Advanced alias for lists.getStyle. Capture list formatting from the abstract definition only (does not merge lvlOverride formatting).', expectedResult: 'Returns a ListsCaptureTemplateResult containing the captured template.', requiresDocumentContext: true, metadata: readOperation({ @@ -1590,7 +1593,8 @@ export const OPERATION_DEFINITIONS = { }, 'lists.setLevelNumbering': { memberPath: 'lists.setLevelNumbering', - description: 'Set the numbering format, pattern, and optional start value for a specific list level.', + description: + 'Advanced alias for lists.setLevelNumberStyle/setLevelText/setLevelStart. Set format, pattern, and start in one call (abstract-scoped, no clone-on-write).', expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the level already matches.', requiresDocumentContext: true, metadata: mutationOperation({ @@ -1716,6 +1720,118 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'lists', }, + // SD-2025 — User-facing list style operations + 'lists.getStyle': { + memberPath: 'lists.getStyle', + description: + 'Read the effective reusable style of a list, including instance-level overrides. Returns a ListStyle that can be applied to other lists via lists.applyStyle.', + expectedResult: 'Returns a ListsGetStyleResult containing the captured style.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'INVALID_INPUT'], + possibleFailureCodes: ['INVALID_TARGET', 'INVALID_INPUT', 'LEVEL_OUT_OF_RANGE'], + }), + referenceDocPath: 'lists/get-style.mdx', + referenceGroup: 'lists', + }, + 'lists.applyStyle': { + memberPath: 'lists.applyStyle', + description: + 'Apply a reusable list style to the target list. Sequence-local: if the abstract definition is shared with other lists, it is cloned first to avoid affecting them.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if all levels already match.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'INVALID_INPUT', 'LEVEL_OUT_OF_RANGE'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/apply-style.mdx', + referenceGroup: 'lists', + }, + 'lists.restartAt': { + memberPath: 'lists.restartAt', + description: + 'Restart numbering at the target list item with a specific value. If the item is mid-sequence, it is separated first.', + expectedResult: 'Returns a ListsMutateItemResult receipt.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'INVALID_INPUT'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/restart-at.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelNumberStyle': { + memberPath: 'lists.setLevelNumberStyle', + description: + 'Set the numbering style (e.g. decimal, lowerLetter, upperRoman) for a specific list level. Rejects "bullet" — use setLevelBullet instead. Sequence-local: clones shared definitions.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the value already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'INVALID_INPUT', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/set-level-number-style.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelText': { + memberPath: 'lists.setLevelText', + description: + 'Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the value already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'INVALID_INPUT', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-level-text.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelStart': { + memberPath: 'lists.setLevelStart', + description: + 'Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the value already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'INVALID_INPUT', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/set-level-start.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelLayout': { + memberPath: 'lists.setLevelLayout', + description: + 'Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates — omitted fields are left unchanged. Sequence-local: clones shared definitions.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if all values already match.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'INVALID_INPUT', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/set-level-layout.mdx', + referenceGroup: 'lists', + }, + 'comments.create': { memberPath: 'comments.create', description: 'Create a new comment thread (or reply when parentCommentId is given).', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 7158ff066e..59e1640922 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -103,6 +103,14 @@ import type { ListsSetLevelTrailingCharacterInput, ListsSetLevelMarkerFontInput, ListsClearLevelOverridesInput, + ListsGetStyleInput, + ListsGetStyleResult, + ListsApplyStyleInput, + ListsRestartAtInput, + ListsSetLevelNumberStyleInput, + ListsSetLevelTextInput, + ListsSetLevelStartInput, + ListsSetLevelLayoutInput, } from '../lists/lists.types.js'; import type { ParagraphMutationResult, @@ -690,6 +698,19 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { output: ListsMutateItemResult; }; + // --- lists.* (SD-2025 user-facing) --- + 'lists.getStyle': { input: ListsGetStyleInput; options: never; output: ListsGetStyleResult }; + 'lists.applyStyle': { input: ListsApplyStyleInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.restartAt': { input: ListsRestartAtInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.setLevelNumberStyle': { + input: ListsSetLevelNumberStyleInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.setLevelText': { input: ListsSetLevelTextInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.setLevelStart': { input: ListsSetLevelStartInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.setLevelLayout': { input: ListsSetLevelLayoutInput; options: MutationOptions; output: ListsMutateItemResult }; + // --- sections.* --- 'sections.list': { input: SectionsListQuery | undefined; options: never; output: SectionsListResult }; 'sections.get': { input: SectionsGetInput; options: never; output: SectionInfo }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index c44a234f81..8a35aa04e9 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -3476,12 +3476,74 @@ const operationSchemas: Record = { target: ref('BlockAddressOrRange'), kind: listKindSchema, level: { type: 'integer', minimum: 0, maximum: 8 }, + preset: { + enum: [ + 'decimal', + 'decimalParenthesis', + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'disc', + 'circle', + 'square', + 'dash', + ], + }, + style: objectSchema( + { + version: { const: 1 }, + levels: arraySchema( + objectSchema( + { + level: { type: 'integer', minimum: 0, maximum: 8 }, + numFmt: { type: 'string' }, + lvlText: { type: 'string' }, + start: { type: 'integer' }, + alignment: { enum: ['left', 'center', 'right'] }, + indents: objectSchema({ + left: { type: 'integer' }, + hanging: { type: 'integer' }, + firstLine: { type: 'integer' }, + }), + trailingCharacter: { enum: ['tab', 'space', 'nothing'] }, + markerFont: { type: 'string' }, + pictureBulletId: { type: 'integer' }, + tabStopAt: { type: ['integer', 'null'] }, + }, + ['level'], + ), + ), + }, + ['version', 'levels'], + ), + sequence: { + oneOf: [ + objectSchema({ mode: { const: 'new' }, startAt: { type: 'integer', minimum: 1 } }, ['mode']), + objectSchema({ mode: { const: 'continuePrevious' } }, ['mode']), + ], + }, }, - required: ['mode', 'kind'], + required: ['mode'], additionalProperties: false, - if: { properties: { mode: { const: 'empty' } } }, - then: { required: ['mode', 'kind', 'at'] }, - else: { required: ['mode', 'kind', 'target'] }, + allOf: [ + // mode-conditional: 'empty' requires 'at', 'fromParagraphs' requires 'target' + { + if: { properties: { mode: { const: 'empty' } } }, + then: { required: ['mode', 'at'] }, + else: { required: ['mode', 'target'] }, + }, + // continuePrevious is incompatible with preset/style + { + if: { + properties: { sequence: { properties: { mode: { const: 'continuePrevious' } }, required: ['mode'] } }, + required: ['sequence'], + }, + then: { + not: { anyOf: [{ required: ['preset'] }, { required: ['style'] }] }, + }, + }, + ], }, output: { oneOf: [ @@ -3929,6 +3991,164 @@ const operationSchemas: Record = { failure: listsFailureSchemaFor('lists.clearLevelOverrides'), }, + // SD-2025 — User-facing list style operations + 'lists.getStyle': (() => { + const listLevelTemplateSchema = objectSchema( + { + level: { type: 'integer', minimum: 0, maximum: 8 }, + numFmt: { type: 'string' }, + lvlText: { type: 'string' }, + start: { type: 'integer' }, + alignment: { enum: ['left', 'center', 'right'] }, + indents: objectSchema({ + left: { type: 'integer' }, + hanging: { type: 'integer' }, + firstLine: { type: 'integer' }, + }), + trailingCharacter: { enum: ['tab', 'space', 'nothing'] }, + markerFont: { type: 'string' }, + pictureBulletId: { type: 'integer' }, + tabStopAt: { type: ['integer', 'null'] }, + }, + ['level'], + ); + const styleSchema = objectSchema( + { + version: { const: 1 }, + levels: arraySchema(listLevelTemplateSchema), + }, + ['version', 'levels'], + ); + const successSchema = objectSchema( + { + success: { const: true }, + style: styleSchema, + }, + ['success', 'style'], + ); + return { + input: objectSchema( + { + target: listItemAddressSchema, + levels: arraySchema({ type: 'integer', minimum: 0, maximum: 8 }), + }, + ['target'], + ), + output: { oneOf: [successSchema, listsFailureSchemaFor('lists.getStyle')] }, + success: successSchema, + failure: listsFailureSchemaFor('lists.getStyle'), + }; + })(), + 'lists.applyStyle': { + input: objectSchema( + { + target: listItemAddressSchema, + style: objectSchema( + { + version: { const: 1 }, + levels: arraySchema( + objectSchema( + { + level: { type: 'integer', minimum: 0, maximum: 8 }, + numFmt: { type: 'string' }, + lvlText: { type: 'string' }, + start: { type: 'integer' }, + alignment: { enum: ['left', 'center', 'right'] }, + indents: objectSchema({ + left: { type: 'integer' }, + hanging: { type: 'integer' }, + firstLine: { type: 'integer' }, + }), + trailingCharacter: { enum: ['tab', 'space', 'nothing'] }, + markerFont: { type: 'string' }, + pictureBulletId: { type: 'integer' }, + tabStopAt: { type: ['integer', 'null'] }, + }, + ['level'], + ), + ), + }, + ['version', 'levels'], + ), + levels: arraySchema({ type: 'integer', minimum: 0, maximum: 8 }), + }, + ['target', 'style'], + ), + output: listsMutateItemResultSchemaFor('lists.applyStyle'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.applyStyle'), + }, + 'lists.restartAt': { + input: objectSchema( + { + target: listItemAddressSchema, + startAt: { type: 'integer', minimum: 1 }, + }, + ['target', 'startAt'], + ), + output: listsMutateItemResultSchemaFor('lists.restartAt'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.restartAt'), + }, + 'lists.setLevelNumberStyle': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + numberStyle: { type: 'string' }, + }, + ['target', 'level', 'numberStyle'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelNumberStyle'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelNumberStyle'), + }, + 'lists.setLevelText': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + text: { type: 'string' }, + }, + ['target', 'level', 'text'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelText'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelText'), + }, + 'lists.setLevelStart': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + startAt: { type: 'integer', minimum: 1 }, + }, + ['target', 'level', 'startAt'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelStart'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelStart'), + }, + 'lists.setLevelLayout': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + layout: objectSchema({ + alignment: { enum: ['left', 'center', 'right'] }, + alignedAt: { type: 'integer' }, + textIndentAt: { type: 'integer' }, + followCharacter: { enum: ['tab', 'space', 'nothing'] }, + tabStopAt: { type: ['integer', 'null'] }, + }), + }, + ['target', 'level', 'layout'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelLayout'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelLayout'), + }, + 'comments.create': { input: objectSchema( { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 6fea2206b3..826ca9ca84 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -159,6 +159,14 @@ import type { ListsSetLevelMarkerFontInput, ListsClearLevelOverridesInput, ListsSetTypeInput, + ListsGetStyleInput, + ListsGetStyleResult, + ListsApplyStyleInput, + ListsRestartAtInput, + ListsSetLevelNumberStyleInput, + ListsSetLevelTextInput, + ListsSetLevelStartInput, + ListsSetLevelLayoutInput, } from './lists/lists.types.js'; import { executeListsGet, @@ -190,6 +198,13 @@ import { executeListsSetLevelMarkerFont, executeListsClearLevelOverrides, executeListsSetType, + executeListsGetStyle, + executeListsApplyStyle, + executeListsRestartAt, + executeListsSetLevelNumberStyle, + executeListsSetLevelText, + executeListsSetLevelStart, + executeListsSetLevelLayout, } from './lists/lists.js'; import { executeReplace, type ReplaceInput } from './replace/replace.js'; import type { CreateAdapter, CreateApi } from './create/create.js'; @@ -1217,6 +1232,18 @@ export type { ListsSetLevelMarkerFontInput, ListsClearLevelOverridesInput, ListsSetTypeInput, + ListStyle, + ListLevelStyle, + ListLevelLayout, + ListsGetStyleInput, + ListsGetStyleResult, + ListsGetStyleSuccessResult, + ListsApplyStyleInput, + ListsRestartAtInput, + ListsSetLevelNumberStyleInput, + ListsSetLevelTextInput, + ListsSetLevelStartInput, + ListsSetLevelLayoutInput, } from './lists/lists.types.js'; export { LIST_KINDS, @@ -2085,6 +2112,29 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { setType(input: ListsSetTypeInput, options?: MutationOptions): ListsMutateItemResult { return executeListsSetType(adapters.lists, input, options); }, + + // SD-2025 user-facing operations + getStyle(input: ListsGetStyleInput): ListsGetStyleResult { + return executeListsGetStyle(adapters.lists, input); + }, + applyStyle(input: ListsApplyStyleInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsApplyStyle(adapters.lists, input, options); + }, + restartAt(input: ListsRestartAtInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsRestartAt(adapters.lists, input, options); + }, + setLevelNumberStyle(input: ListsSetLevelNumberStyleInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelNumberStyle(adapters.lists, input, options); + }, + setLevelText(input: ListsSetLevelTextInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelText(adapters.lists, input, options); + }, + setLevelStart(input: ListsSetLevelStartInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelStart(adapters.lists, input, options); + }, + setLevelLayout(input: ListsSetLevelLayoutInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelLayout(adapters.lists, input, options); + }, }, sections: { list(query?: SectionsListQuery): SectionsListResult { diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index f85d1142ba..87a3594f90 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -147,6 +147,15 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'lists.setLevelMarkerFont': (input, options) => api.lists.setLevelMarkerFont(input, options), 'lists.clearLevelOverrides': (input, options) => api.lists.clearLevelOverrides(input, options), + // --- lists.* (SD-2025 user-facing) --- + 'lists.getStyle': (input) => api.lists.getStyle(input), + 'lists.applyStyle': (input, options) => api.lists.applyStyle(input, options), + 'lists.restartAt': (input, options) => api.lists.restartAt(input, options), + 'lists.setLevelNumberStyle': (input, options) => api.lists.setLevelNumberStyle(input, options), + 'lists.setLevelText': (input, options) => api.lists.setLevelText(input, options), + 'lists.setLevelStart': (input, options) => api.lists.setLevelStart(input, options), + 'lists.setLevelLayout': (input, options) => api.lists.setLevelLayout(input, options), + // --- sections.* --- 'sections.list': (input) => api.sections.list(input), 'sections.get': (input) => api.sections.get(input), diff --git a/packages/document-api/src/lists/lists.ts b/packages/document-api/src/lists/lists.ts index 58e41611fb..2de3fb2e33 100644 --- a/packages/document-api/src/lists/lists.ts +++ b/packages/document-api/src/lists/lists.ts @@ -42,6 +42,14 @@ import type { ListsSetLevelMarkerFontInput, ListsClearLevelOverridesInput, ListsSetTypeInput, + ListsGetStyleInput, + ListsGetStyleResult, + ListsApplyStyleInput, + ListsRestartAtInput, + ListsSetLevelNumberStyleInput, + ListsSetLevelTextInput, + ListsSetLevelStartInput, + ListsSetLevelLayoutInput, } from './lists.types.js'; export type { @@ -85,6 +93,14 @@ export type { ListsSetLevelMarkerFontInput, ListsClearLevelOverridesInput, ListsSetTypeInput, + ListsGetStyleInput, + ListsGetStyleResult, + ListsApplyStyleInput, + ListsRestartAtInput, + ListsSetLevelNumberStyleInput, + ListsSetLevelTextInput, + ListsSetLevelStartInput, + ListsSetLevelLayoutInput, } from './lists.types.js'; /** @@ -142,6 +158,15 @@ export interface ListsAdapter { // SD-2052 compound operation setType(input: ListsSetTypeInput, options?: MutationOptions): ListsMutateItemResult; + + // SD-2025 user-facing operations + getStyle(input: ListsGetStyleInput): ListsGetStyleResult; + applyStyle(input: ListsApplyStyleInput, options?: MutationOptions): ListsMutateItemResult; + restartAt(input: ListsRestartAtInput, options?: MutationOptions): ListsMutateItemResult; + setLevelNumberStyle(input: ListsSetLevelNumberStyleInput, options?: MutationOptions): ListsMutateItemResult; + setLevelText(input: ListsSetLevelTextInput, options?: MutationOptions): ListsMutateItemResult; + setLevelStart(input: ListsSetLevelStartInput, options?: MutationOptions): ListsMutateItemResult; + setLevelLayout(input: ListsSetLevelLayoutInput, options?: MutationOptions): ListsMutateItemResult; } export type ListsApi = ListsAdapter; @@ -405,3 +430,66 @@ export function executeListsSetType( validateListTarget(input, 'lists.setType'); return adapter.setType(input, normalizeMutationOptions(options)); } + +// --------------------------------------------------------------------------- +// Execute wrappers — SD-2025 user-facing operations +// --------------------------------------------------------------------------- + +export function executeListsGetStyle(adapter: ListsAdapter, input: ListsGetStyleInput): ListsGetStyleResult { + validateListTarget(input, 'lists.getStyle'); + return adapter.getStyle(input); +} + +export function executeListsApplyStyle( + adapter: ListsAdapter, + input: ListsApplyStyleInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.applyStyle'); + return adapter.applyStyle(input, normalizeMutationOptions(options)); +} + +export function executeListsRestartAt( + adapter: ListsAdapter, + input: ListsRestartAtInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.restartAt'); + return adapter.restartAt(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelNumberStyle( + adapter: ListsAdapter, + input: ListsSetLevelNumberStyleInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelNumberStyle'); + return adapter.setLevelNumberStyle(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelText( + adapter: ListsAdapter, + input: ListsSetLevelTextInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelText'); + return adapter.setLevelText(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelStart( + adapter: ListsAdapter, + input: ListsSetLevelStartInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelStart'); + return adapter.setLevelStart(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelLayout( + adapter: ListsAdapter, + input: ListsSetLevelLayoutInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelLayout'); + return adapter.setLevelLayout(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/lists/lists.types.ts b/packages/document-api/src/lists/lists.types.ts index 60d4a8057f..cf9acaf0ed 100644 --- a/packages/document-api/src/lists/lists.types.ts +++ b/packages/document-api/src/lists/lists.types.ts @@ -154,9 +154,49 @@ export interface ListTargetInput { // Input types — new SD-1272 operations // --------------------------------------------------------------------------- +/** + * Create a new list from existing paragraphs. + * + * When `sequence.mode` is `'continuePrevious'`, `preset` and `style` are + * not allowed — the new items inherit formatting from the previous sequence. + */ export type ListsCreateInput = - | { mode: 'empty'; at: BlockAddress; kind: ListKind; level?: number } - | { mode: 'fromParagraphs'; target: BlockAddress | BlockRange; kind: ListKind; level?: number }; + | { + mode: 'empty'; + at: BlockAddress; + kind?: ListKind; + level?: number; + preset?: ListPresetId; + style?: ListStyle; + sequence?: { mode: 'new'; startAt?: number }; + } + | { + mode: 'empty'; + at: BlockAddress; + kind?: ListKind; + level?: number; + preset?: never; + style?: never; + sequence: { mode: 'continuePrevious' }; + } + | { + mode: 'fromParagraphs'; + target: BlockAddress | BlockRange; + kind?: ListKind; + level?: number; + preset?: ListPresetId; + style?: ListStyle; + sequence?: { mode: 'new'; startAt?: number }; + } + | { + mode: 'fromParagraphs'; + target: BlockAddress | BlockRange; + kind?: ListKind; + level?: number; + preset?: never; + style?: never; + sequence: { mode: 'continuePrevious' }; + }; export interface ListsAttachInput { target: BlockAddress | BlockRange; @@ -232,6 +272,7 @@ export interface ListLevelTemplate { trailingCharacter?: TrailingCharacter; markerFont?: string; pictureBulletId?: number; + tabStopAt?: number | null; } /** A full list template: an array of level snapshots. */ @@ -240,6 +281,33 @@ export interface ListTemplate { levels: ListLevelTemplate[]; } +// --------------------------------------------------------------------------- +// SD-2025 user-facing style aliases and new types +// --------------------------------------------------------------------------- + +/** Reusable list style object — alias of ListTemplate for user-facing naming. */ +export type ListStyle = ListTemplate; + +/** Reusable level style — alias of ListLevelTemplate for user-facing naming. */ +export type ListLevelStyle = ListLevelTemplate; + +/** + * Dialog-shaped layout input for `lists.setLevelLayout`. + * All numeric values are in twips. + * + * Partial-update semantics: + * - undefined (omitted): leave current value unchanged + * - null (explicit): remove/clear the value (only valid for tabStopAt) + * - present value: set it + */ +export interface ListLevelLayout { + alignment?: LevelAlignment; + alignedAt?: number; + textIndentAt?: number; + followCharacter?: TrailingCharacter; + tabStopAt?: number | null; +} + // --------------------------------------------------------------------------- // Input types — SD-1973 formatting operations // --------------------------------------------------------------------------- @@ -321,6 +389,61 @@ export interface ListsClearLevelOverridesInput { level: number; } +// --------------------------------------------------------------------------- +// Input types — SD-2025 user-facing operations +// --------------------------------------------------------------------------- + +export interface ListsGetStyleInput { + target: ListItemAddress; + levels?: number[]; +} + +export interface ListsApplyStyleInput { + target: ListItemAddress; + style: ListStyle; + levels?: number[]; +} + +export interface ListsRestartAtInput { + target: ListItemAddress; + startAt: number; +} + +export interface ListsSetLevelNumberStyleInput { + target: ListItemAddress; + level: number; + numberStyle: string; +} + +export interface ListsSetLevelTextInput { + target: ListItemAddress; + level: number; + text: string; +} + +export interface ListsSetLevelStartInput { + target: ListItemAddress; + level: number; + startAt: number; +} + +export interface ListsSetLevelLayoutInput { + target: ListItemAddress; + level: number; + layout: ListLevelLayout; +} + +// --------------------------------------------------------------------------- +// Result types — SD-2025 +// --------------------------------------------------------------------------- + +export interface ListsGetStyleSuccessResult { + success: true; + style: ListStyle; +} + +export type ListsGetStyleResult = ListsGetStyleSuccessResult | ListsFailureResult; + // --------------------------------------------------------------------------- // Result types — SD-1973 // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js index 4997906167..70a8bffd93 100644 --- a/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js +++ b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js @@ -126,7 +126,7 @@ function hasLevel(editor, abstractNumId, ilvl) { * Read all formatting properties from a raw `w:lvl` element. * @param {Object} lvlEl * @param {number} ilvl - * @returns {{ level: number, numFmt?: string, lvlText?: string, start?: number, alignment?: string, indents?: { left?: number, hanging?: number, firstLine?: number }, trailingCharacter?: string, markerFont?: string, pictureBulletId?: number }} + * @returns {{ level: number, numFmt?: string, lvlText?: string, start?: number, alignment?: string, indents?: { left?: number, hanging?: number, firstLine?: number }, trailingCharacter?: string, markerFont?: string, pictureBulletId?: number, tabStopAt?: number }} */ function readLevelProperties(lvlEl, ilvl) { /** @type {any} */ @@ -160,6 +160,10 @@ function readLevelProperties(lvlEl, ilvl) { if (Object.keys(indents).length > 0) props.indents = indents; } + // Read tab stop from w:pPr/w:tabs/w:tab within w:lvl + const tabStopVal = readLevelTabStop(pPr); + if (tabStopVal != null) props.tabStopAt = tabStopVal; + const rPr = lvlEl.elements?.find((el) => el.name === 'w:rPr'); const rFonts = rPr?.elements?.find((el) => el.name === 'w:rFonts'); if (rFonts?.attributes?.['w:ascii']) { @@ -169,6 +173,147 @@ function readLevelProperties(lvlEl, ilvl) { return props; } +// ────────────────────────────────────────────────────────────────────────────── +// Tab Stop Read/Write Helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Read the first tab stop position from a `w:pPr` element. + * List-level tab stops are stored in `w:pPr/w:tabs/w:tab` within the `w:lvl`. + * @param {Object | undefined} pPr + * @returns {number | undefined} + */ +function readLevelTabStop(pPr) { + if (!pPr?.elements) return undefined; + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + if (!tabs?.elements) return undefined; + const tab = tabs.elements.find((el) => el.name === 'w:tab'); + if (!tab?.attributes?.['w:pos']) return undefined; + return Number(tab.attributes['w:pos']); +} + +/** + * Set or remove the list-level tab stop. + * @param {Object} lvlEl - The `w:lvl` element. + * @param {number | null} value - Position in twips, or null to remove. + * @returns {boolean} True if anything changed. + */ +function mutateLevelTabStop(lvlEl, value) { + const pPr = findOrCreateChild(lvlEl, 'w:pPr'); + + if (value === null) { + // Remove the tab stop + const tabsIdx = pPr.elements.findIndex((el) => el.name === 'w:tabs'); + if (tabsIdx === -1) return false; + pPr.elements.splice(tabsIdx, 1); + return true; + } + + const tabs = findOrCreateChild(pPr, 'w:tabs'); + const existing = tabs.elements.find((el) => el.name === 'w:tab'); + const posStr = String(value); + + if (existing) { + if (existing.attributes?.['w:pos'] === posStr && existing.attributes?.['w:val'] === 'num') return false; + existing.attributes = { ...existing.attributes, 'w:val': 'num', 'w:pos': posStr }; + return true; + } + + tabs.elements.push({ + type: 'element', + name: 'w:tab', + attributes: { 'w:val': 'num', 'w:pos': posStr }, + }); + return true; +} + +/** + * Composite setter: resolve abstract + level, then mutate tab stop. + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {number | null} value + * @returns {boolean} + */ +function setLevelTabStop(editor, abstractNumId, ilvl, value) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + return mutateLevelTabStop(resolved.lvlEl, value); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Marker-Mode Normalization Helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Clear the `w:lvlPicBulletId` element from a level if it exists. + * Used for marker-mode normalization when switching away from picture bullets. + * @param {Object} lvlEl + * @returns {boolean} True if an element was removed. + */ +function clearPictureBulletId(lvlEl) { + if (!lvlEl.elements) return false; + const idx = lvlEl.elements.findIndex((el) => el.name === 'w:lvlPicBulletId'); + if (idx === -1) return false; + lvlEl.elements.splice(idx, 1); + return true; +} + +/** + * Set numFmt only (for setLevelNumberStyle). Rejects 'bullet'. + * Clears lvlPicBulletId if present (marker-mode normalization). + * @param {Object} lvlEl + * @param {string} numFmt + * @returns {boolean} + */ +function mutateLevelNumberStyle(lvlEl, numFmt) { + let changed = setChildAttr(lvlEl, 'w:numFmt', numFmt); + changed = clearPictureBulletId(lvlEl) || changed; + return changed; +} + +/** + * Composite setter for setLevelNumberStyle. + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {string} numFmt + * @returns {boolean} + */ +function setLevelNumberStyle(editor, abstractNumId, ilvl, numFmt) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + return mutateLevelNumberStyle(resolved.lvlEl, numFmt); +} + +/** + * Set lvlText only (for setLevelText). + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {string} text + * @returns {boolean} + */ +function setLevelText(editor, abstractNumId, ilvl, text) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + return setChildAttr(resolved.lvlEl, 'w:lvlText', text); +} + +/** + * Set start value only (for setLevelStart). + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {number} start + * @returns {boolean} + */ +function setLevelStart(editor, abstractNumId, ilvl, start) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + return setChildAttr(resolved.lvlEl, 'w:start', String(start)); +} + // ────────────────────────────────────────────────────────────────────────────── // Raw XML Mutators (no sync, no emit) // ────────────────────────────────────────────────────────────────────────────── @@ -524,6 +669,11 @@ function applyTemplateToAbstract(editor, abstractNumId, template, levels) { if (entry.markerFont != null) anyChanged = mutateLevelMarkerFont(lvlEl, entry.markerFont) || anyChanged; if (entry.pictureBulletId != null) anyChanged = mutateLevelPictureBulletId(lvlEl, entry.pictureBulletId) || anyChanged; + + // Apply tabStopAt if present in template + if (entry.tabStopAt !== undefined) { + anyChanged = mutateLevelTabStop(lvlEl, entry.tabStopAt) || anyChanged; + } } return { changed: anyChanged }; @@ -594,6 +744,290 @@ function getPresetTemplate(presetId) { return PRESET_TEMPLATES[presetId]; } +// ────────────────────────────────────────────────────────────────────────────── +// Layout Composite Mutation +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Apply dialog-shaped layout properties to a level element. + * + * Indent mapping: + * textIndentAt → w:ind/@w:left + * alignedAt → derives w:ind/@w:hanging = textIndentAt - alignedAt + * + * Partial-update: omitted fields are untouched. + * Only tabStopAt accepts explicit null (remove). + * + * @param {Object} lvlEl + * @param {{ alignment?: string, alignedAt?: number, textIndentAt?: number, followCharacter?: string, tabStopAt?: number | null }} layout + * @returns {{ changed: boolean, error?: string }} + */ +function mutateLevelLayout(lvlEl, layout) { + let changed = false; + + // Alignment + if (layout.alignment != null) { + changed = mutateLevelAlignment(lvlEl, layout.alignment) || changed; + } + + // Trailing character (followCharacter) + if (layout.followCharacter != null) { + changed = mutateLevelTrailingCharacter(lvlEl, layout.followCharacter) || changed; + } + + // Tab stop + if (layout.tabStopAt !== undefined) { + changed = mutateLevelTabStop(lvlEl, layout.tabStopAt) || changed; + } + + // Indents (dialog → OOXML conversion) + const hasAlignedAt = layout.alignedAt != null; + const hasTextIndentAt = layout.textIndentAt != null; + + if (hasAlignedAt || hasTextIndentAt) { + const pPr = lvlEl.elements?.find((el) => el.name === 'w:pPr'); + const ind = pPr?.elements?.find((el) => el.name === 'w:ind'); + const existingLeft = ind?.attributes?.['w:left'] != null ? Number(ind.attributes['w:left']) : undefined; + const existingHanging = ind?.attributes?.['w:hanging'] != null ? Number(ind.attributes['w:hanging']) : undefined; + const existingFirstLine = + ind?.attributes?.['w:firstLine'] != null ? Number(ind.attributes['w:firstLine']) : undefined; + + // Compute existing alignedAt from current indent state + let existingAlignedAt; + if (existingLeft != null) { + if (existingHanging != null) { + existingAlignedAt = existingLeft - existingHanging; + } else if (existingFirstLine != null) { + existingAlignedAt = existingLeft + existingFirstLine; + } else { + existingAlignedAt = existingLeft; + } + } + + let newLeft, newHanging; + + if (hasAlignedAt && hasTextIndentAt) { + newLeft = layout.textIndentAt; + newHanging = layout.textIndentAt - layout.alignedAt; + } else if (hasTextIndentAt) { + newLeft = layout.textIndentAt; + newHanging = existingAlignedAt != null ? layout.textIndentAt - existingAlignedAt : 0; + } else if (hasAlignedAt) { + if (existingLeft == null) { + return { changed, error: 'INVALID_INPUT' }; + } + newLeft = existingLeft; + newHanging = existingLeft - layout.alignedAt; + } + + if (newLeft != null) { + // Always normalize to hanging (remove firstLine if present) + const indents = { left: newLeft, hanging: newHanging ?? 0 }; + changed = mutateLevelIndents(lvlEl, indents) || changed; + } + } + + return { changed }; +} + +/** + * Composite setter for setLevelLayout. + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {{ alignment?: string, alignedAt?: number, textIndentAt?: number, followCharacter?: string, tabStopAt?: number | null }} layout + * @returns {{ changed: boolean, error?: string }} + */ +function setLevelLayout(editor, abstractNumId, ilvl, layout) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return { changed: false }; + return mutateLevelLayout(resolved.lvlEl, layout); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Effective Style Capture (abstract + lvlOverride merge) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Capture the effective style of a list: abstract definition properties merged + * with any instance-level lvlOverride formatting. Excludes startOverride + * (sequence state, not style). + * + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} numId + * @param {number[] | undefined} levels + * @returns {{ version: 1, levels: Array } | null} + */ +function captureEffectiveStyle(editor, abstractNumId, numId, levels) { + const abstract = editor.converter.numbering?.abstracts?.[abstractNumId]; + if (!abstract?.elements) return null; + + const numDef = editor.converter.numbering?.definitions?.[numId]; + const overridesByLevel = buildOverrideMap(numDef); + + const lvlElements = abstract.elements.filter((el) => el.name === 'w:lvl'); + const captured = []; + + for (const lvlEl of lvlElements) { + const ilvl = Number(lvlEl.attributes?.['w:ilvl']); + if (levels && !levels.includes(ilvl)) continue; + + const baseProps = readLevelProperties(lvlEl, ilvl); + + // Merge lvlOverride formatting (not startOverride) from the num definition + const overrideLvl = overridesByLevel.get(ilvl); + if (overrideLvl) { + const overrideProps = readLevelProperties(overrideLvl, ilvl); + mergeOverrideProps(baseProps, overrideProps); + } + + captured.push(baseProps); + } + + captured.sort((a, b) => a.level - b.level); + return { version: 1, levels: captured }; +} + +/** + * Build a map of level index → w:lvl element from lvlOverride entries. + * Only includes overrides that contain a w:lvl child (formatting overrides), + * not those that only contain w:startOverride. + * @param {Object | undefined} numDef + * @returns {Map} + */ +function buildOverrideMap(numDef) { + const map = new Map(); + if (!numDef?.elements) return map; + + for (const el of numDef.elements) { + if (el.name !== 'w:lvlOverride') continue; + const ilvl = Number(el.attributes?.['w:ilvl']); + const lvlChild = el.elements?.find((c) => c.name === 'w:lvl'); + if (lvlChild) { + map.set(ilvl, lvlChild); + } + } + + return map; +} + +/** + * Merge override properties into base properties. Override values take + * precedence when present (non-undefined). + * @param {Object} base - Mutable base properties from abstract. + * @param {Object} override - Properties from lvlOverride w:lvl. + */ +function mergeOverrideProps(base, override) { + if (override.numFmt != null) base.numFmt = override.numFmt; + if (override.lvlText != null) base.lvlText = override.lvlText; + if (override.start != null) base.start = override.start; + if (override.alignment != null) base.alignment = override.alignment; + if (override.indents != null) base.indents = { ...base.indents, ...override.indents }; + if (override.trailingCharacter != null) base.trailingCharacter = override.trailingCharacter; + if (override.markerFont != null) base.markerFont = override.markerFont; + if (override.pictureBulletId != null) base.pictureBulletId = override.pictureBulletId; + if (override.tabStopAt != null) base.tabStopAt = override.tabStopAt; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Clone-on-Write Helper +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Check whether the given abstractNumId is referenced by any other w:num + * besides the given numId. + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} numId + * @returns {boolean} + */ +function isAbstractShared(editor, abstractNumId, numId) { + const definitions = editor.converter.numbering?.definitions; + if (!definitions) return false; + + for (const [defNumId, numDef] of Object.entries(definitions)) { + if (Number(defNumId) === numId) continue; + if (!numDef?.elements) continue; + const absEl = numDef.elements.find((el) => el.name === 'w:abstractNumId'); + if (absEl && Number(absEl.attributes?.['w:val']) === abstractNumId) { + return true; + } + } + return false; +} + +/** + * Deep clone an XML element tree (preserves all children, attributes, unknown extensions). + * @param {Object} element + * @returns {Object} + */ +function deepCloneElement(element) { + const clone = { ...element }; + if (element.attributes) { + clone.attributes = { ...element.attributes }; + } + if (element.elements) { + clone.elements = element.elements.map((child) => deepCloneElement(child)); + } + return clone; +} + +/** + * Clone an abstract definition and create a new w:num pointing to it. + * Returns the new abstractNumId and numId. + * + * @param {import('../Editor').Editor} editor + * @param {number} originalAbstractNumId + * @param {number} originalNumId + * @returns {{ newAbstractNumId: number, newNumId: number }} + */ +function cloneAbstractAndNum(editor, originalAbstractNumId, originalNumId) { + const numbering = editor.converter.numbering; + + // Find next available abstractNumId + const existingAbstractIds = Object.keys(numbering.abstracts).map(Number); + const newAbstractNumId = existingAbstractIds.length > 0 ? Math.max(...existingAbstractIds) + 1 : 0; + + // Clone the abstract definition + const original = numbering.abstracts[originalAbstractNumId]; + const cloned = deepCloneElement(original); + cloned.attributes = { ...cloned.attributes, 'w:abstractNumId': String(newAbstractNumId) }; + numbering.abstracts[newAbstractNumId] = cloned; + + // Find next available numId + const existingNumIds = Object.keys(numbering.definitions).map(Number); + const newNumId = existingNumIds.length > 0 ? Math.max(...existingNumIds) + 1 : 1; + + // Create new w:num pointing to cloned abstract, copying lvlOverride entries + const originalNumDef = numbering.definitions[originalNumId]; + const newElements = [ + { + type: 'element', + name: 'w:abstractNumId', + attributes: { 'w:val': String(newAbstractNumId) }, + }, + ]; + + // Copy any lvlOverride entries from the original w:num + if (originalNumDef?.elements) { + for (const el of originalNumDef.elements) { + if (el.name === 'w:lvlOverride') { + newElements.push(deepCloneElement(el)); + } + } + } + + numbering.definitions[newNumId] = { + type: 'element', + name: 'w:num', + attributes: { 'w:numId': String(newNumId) }, + elements: newElements, + }; + + return { newAbstractNumId, newNumId }; +} + // ────────────────────────────────────────────────────────────────────────────── // Exports // ────────────────────────────────────────────────────────────────────────────── @@ -612,6 +1046,13 @@ export const LevelFormattingHelpers = { setLevelIndents, setLevelTrailingCharacter, setLevelMarkerFont, + setLevelTabStop, + + // SD-2025 decomposed setters + setLevelNumberStyle, + setLevelText, + setLevelStart, + setLevelLayout, // Override clearing hasLevelOverride, @@ -621,6 +1062,13 @@ export const LevelFormattingHelpers = { captureTemplate, applyTemplateToAbstract, + // Effective style (abstract + lvlOverride) + captureEffectiveStyle, + + // Clone-on-write + isAbstractShared, + cloneAbstractAndNum, + // Preset catalog getPresetTemplate, PRESET_TEMPLATES, diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 082e48316b..5fa0172627 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -166,6 +166,14 @@ import { listsSetLevelTrailingCharacterWrapper, listsSetLevelMarkerFontWrapper, listsClearLevelOverridesWrapper, + listsGetStyleWrapper, + listsApplyStyleWrapper, + listsRestartAtWrapper, + listsSetLevelNumberStyleWrapper, + listsSetLevelTextWrapper, + listsSetLevelStartWrapper, + listsSetLevelLayoutWrapper, + registerSetValueDelegate, } from '../plan-engine/lists-formatting-wrappers.js'; import * as listSequenceHelpers from '../helpers/list-sequence-helpers.js'; import { LevelFormattingHelpers } from '../../core/helpers/list-level-formatting-helpers.js'; @@ -5466,6 +5474,224 @@ const mutationVectors: Partial> = { return result; }, }, + // SD-2025 user-centric list formatting operations + 'lists.applyStyle': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsApplyStyleWrapper( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + style: { version: 1, levels: [] }, + }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsApplyStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + style: { version: 99 as any, levels: [] }, + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const applySpy = vi + .spyOn(LevelFormattingHelpers, 'applyTemplateToAbstract') + .mockImplementation((_ed: unknown) => { + injectNumberingChange(_ed); + return { changed: true, levelsApplied: [0] }; + }); + const result = listsApplyStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + style: { version: 1, levels: [{ level: 0, numFmt: 'upperRoman', lvlText: '%1.' }] }, + }); + abstractSpy.mockRestore(); + applySpy.mockRestore(); + return result; + }, + }, + 'lists.restartAt': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsRestartAtWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, startAt: 5 }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsRestartAtWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + startAt: 0, + }); + }, + applyCase: () => { + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(true); + const overrideSpy = vi.spyOn(ListHelpers, 'setLvlOverride').mockImplementation(() => {}); + registerSetValueDelegate((ed, input, options) => listsSetValueWrapper(ed, input, options)); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsRestartAtWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + startAt: 5, + }); + firstInSeqSpy.mockRestore(); + overrideSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelNumberStyle': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelNumberStyleWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, numberStyle: 'upperRoman' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelNumberStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + numberStyle: 'bullet', + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelNumberStyle').mockImplementation((_ed: unknown) => { + injectNumberingChange(_ed); + return true; + }); + const result = listsSetLevelNumberStyleWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + numberStyle: 'upperRoman', + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelText': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelTextWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, text: '%1.' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelTextWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + text: '%1.', + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelText').mockImplementation((_ed: unknown) => { + injectNumberingChange(_ed); + return true; + }); + const result = listsSetLevelTextWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + text: '%1.', + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelStart': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelStartWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, startAt: 5 }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelStartWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + startAt: 0, + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const readSpy = vi + .spyOn(LevelFormattingHelpers, 'readLevelProperties') + .mockReturnValue({ numFmt: 'decimal' } as any); + const findSpy = vi.spyOn(LevelFormattingHelpers, 'findLevelElement').mockReturnValue({} as any); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelStart').mockImplementation((_ed: unknown) => { + injectNumberingChange(_ed); + return true; + }); + const result = listsSetLevelStartWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + startAt: 5, + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + readSpy.mockRestore(); + findSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelLayout': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelLayoutWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, layout: { alignment: 'left' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelLayoutWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + layout: { alignment: 'left' }, + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelLayout').mockImplementation((_ed: unknown) => { + injectNumberingChange(_ed); + return { changed: true }; + }); + const result = listsSetLevelLayoutWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + layout: { alignment: 'left' }, + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, 'comments.create': { throwCase: () => { const editor = makeCommentsEditor([], { addComment: undefined }); @@ -8794,6 +9020,88 @@ const dryRunVectors: Partial unknown>> = { { changeMode: 'direct', dryRun: true }, ); }, + // SD-2025 user-centric list formatting — dryRun vectors + 'lists.applyStyle': () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsApplyStyleWrapper( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + style: { version: 1, levels: [{ level: 0, numFmt: 'decimal', lvlText: '%1.' }] }, + }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + return result; + }, + 'lists.restartAt': () => { + registerSetValueDelegate((ed, input, options) => listsSetValueWrapper(ed, input, options)); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsRestartAtWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, startAt: 5 }, + { changeMode: 'direct', dryRun: true }, + ); + }, + 'lists.setLevelNumberStyle': () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelNumberStyleWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, numberStyle: 'upperRoman' }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelText': () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelTextWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, text: '%1.' }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelStart': () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const readSpy = vi + .spyOn(LevelFormattingHelpers, 'readLevelProperties') + .mockReturnValue({ numFmt: 'decimal' } as any); + const findSpy = vi.spyOn(LevelFormattingHelpers, 'findLevelElement').mockReturnValue({} as any); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelStartWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, startAt: 5 }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + readSpy.mockRestore(); + findSpy.mockRestore(); + return result; + }, + 'lists.setLevelLayout': () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelLayoutWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, layout: { alignment: 'left' } }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, // ------------------------------------------------------------------------- // Table operations — dryRun vectors diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index a13dc3c821..3cb7bb57f4 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -81,6 +81,14 @@ import { listsSetLevelTrailingCharacterWrapper, listsSetLevelMarkerFontWrapper, listsClearLevelOverridesWrapper, + listsGetStyleWrapper, + listsApplyStyleWrapper, + listsRestartAtWrapper, + listsSetLevelNumberStyleWrapper, + listsSetLevelTextWrapper, + listsSetLevelStartWrapper, + listsSetLevelLayoutWrapper, + registerSetValueDelegate, } from './plan-engine/lists-formatting-wrappers.js'; import { executePlan } from './plan-engine/executor.js'; import { previewPlan } from './plan-engine/preview.js'; @@ -327,6 +335,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters const ccAdapter = createContentControlsAdapter(editor); + // Register the setValue delegate for the restartAt wrapper + registerSetValueDelegate((ed, input, options) => listsSetValueWrapper(ed, input, options)); + return { get: { get: (input) => getAdapter(editor, input), @@ -444,6 +455,15 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters setLevelMarkerFont: (input, options) => listsSetLevelMarkerFontWrapper(editor, input, options), clearLevelOverrides: (input, options) => listsClearLevelOverridesWrapper(editor, input, options), setType: (input, options) => listsSetTypeWrapper(editor, input, options), + + // SD-2025 user-facing operations + getStyle: (input) => listsGetStyleWrapper(editor, input), + applyStyle: (input, options) => listsApplyStyleWrapper(editor, input, options), + restartAt: (input, options) => listsRestartAtWrapper(editor, input, options), + setLevelNumberStyle: (input, options) => listsSetLevelNumberStyleWrapper(editor, input, options), + setLevelText: (input, options) => listsSetLevelTextWrapper(editor, input, options), + setLevelStart: (input, options) => listsSetLevelStartWrapper(editor, input, options), + setLevelLayout: (input, options) => listsSetLevelLayoutWrapper(editor, input, options), }, sections: { list: (query) => sectionsListAdapter(editor, query), diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts index 9da64fbc51..6ba3ff565c 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts @@ -29,6 +29,14 @@ import type { ListTemplate, MutationOptions, ReceiptFailureCode, + ListsGetStyleInput, + ListsGetStyleResult, + ListsApplyStyleInput, + ListsRestartAtInput, + ListsSetLevelNumberStyleInput, + ListsSetLevelTextInput, + ListsSetLevelStartInput, + ListsSetLevelLayoutInput, } from '@superdoc/document-api'; import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { mutatePart } from '../../core/parts/mutation/mutate-part.js'; @@ -72,9 +80,9 @@ function validateLevel(level: number): ListsMutateItemResult | null { } /** - * Validate the `levels` array for multi-level operations. - * Must be unique, sorted ascending, and each entry 0–8. - * Returns a failure result if invalid, or null if valid. + * Validate and normalize the `levels` array for multi-level operations. + * Range-checks each entry (0–8). Silently deduplicates and sorts. + * Returns a failure result if out-of-range, or null if valid. */ function validateLevelsArray( levels: number[] | undefined, @@ -87,19 +95,17 @@ function validateLevelsArray( } } - if (new Set(levels).size !== levels.length) { - return toListsFailure('INVALID_INPUT', 'levels must contain unique values.', { levels }); - } - - for (let i = 1; i < levels.length; i++) { - if (levels[i] <= levels[i - 1]) { - return toListsFailure('INVALID_INPUT', 'levels must be sorted in ascending order.', { levels }); - } - } - return null; } +/** + * Normalize a levels array: deduplicate and sort ascending. + */ +function normalizeLevels(levels: number[] | undefined): number[] | undefined { + if (!levels) return undefined; + return [...new Set(levels)].sort((a, b) => a - b); +} + /** * Preflight check for template/preset application — validates that every * requested level exists in the template. This runs before dry-run returns @@ -807,6 +813,463 @@ export function listsSetLevelMarkerFontWrapper( ); } +// --------------------------------------------------------------------------- +// SD-2025 — Clone-on-write helper +// --------------------------------------------------------------------------- + +/** + * Result of a clone-on-write check. If cloning was needed, `pendingRebind` + * contains the items that must be rebound to the new numId. The caller + * MUST apply the rebinding in the same PM transaction as the actual mutation + * so that a subsequent NO_OP / failure leaves the document unchanged. + */ +type SequenceLocalResult = { + abstractNumId: number; + numId: number; + pendingRebind: ListItemProjection[] | null; +}; + +/** + * Ensure the target sequence has its own private abstract definition. + * If the abstract is shared, clones the abstract and creates a new w:num + * in converter data ONLY (no PM dispatch). Returns the rebinding info + * so the caller can apply it in its own transaction. + */ +function ensureSequenceLocalAbstract( + editor: Editor, + target: ListItemProjection, + targetAbstractNumId: number, + targetNumId: number, +): SequenceLocalResult { + if (!LevelFormattingHelpers.isAbstractShared(editor, targetAbstractNumId, targetNumId)) { + return { abstractNumId: targetAbstractNumId, numId: targetNumId, pendingRebind: null }; + } + + const { newAbstractNumId, newNumId } = LevelFormattingHelpers.cloneAbstractAndNum( + editor, + targetAbstractNumId, + targetNumId, + ); + + const sequence = getContiguousSequence(editor, target); + return { abstractNumId: newAbstractNumId, numId: newNumId, pendingRebind: sequence }; +} + +/** + * Apply pending rebind operations to a PM transaction. + * Must be called within the same transaction as the actual mutation. + */ +function applyPendingRebind(editor: Editor, tr: unknown, local: SequenceLocalResult): void { + if (!local.pendingRebind) return; + for (const item of local.pendingRebind) { + updateNumberingProperties( + { numId: local.numId, ilvl: item.level ?? 0 }, + item.candidate.node, + item.candidate.pos, + editor, + tr, + ); + } +} + +/** + * Execute a single-level mutation with clone-on-write semantics. + * Like `executeSingleLevelMutation` but ensures the abstract is sequence-local first. + */ +function executeSequenceLocalLevelMutation( + editor: Editor, + operationId: string, + target: { kind: 'block'; nodeType: 'listItem'; nodeId: string }, + level: number, + options: MutationOptions | undefined, + mutate: (abstractNumId: number, ilvl: number) => boolean, +): ListsMutateItemResult { + rejectTrackedMode(operationId, options); + + const levelError = validateLevel(level); + if (levelError) return levelError; + + const targetResult = resolveTargetAbstract(editor, target); + if (!targetResult.ok) return (targetResult as TargetAbstractFailure).failure; + + if (!LevelFormattingHelpers.hasLevel(editor, targetResult.abstractNumId, level)) { + return toListsFailure('LEVEL_NOT_FOUND', `Level ${level} does not exist in the abstract definition.`, { + target, + level, + }); + } + + if (options?.dryRun) { + return { success: true, item: targetResult.resolved.address }; + } + + let noOp = false; + + const compound = compoundMutation({ + editor, + source: operationId, + affectedParts: [NUMBERING_PART], + execute() { + // Clone-on-write: prepare sequence-local abstract (converter-only, no PM dispatch). + // This is cheap (just converter data) and will be rolled back by compoundMutation + // if we return false. + const resolved = resolveListItem(editor, target); + const local = ensureSequenceLocalAbstract(editor, resolved, targetResult.abstractNumId, targetResult.numId); + + const result = mutatePart({ + editor, + partId: NUMBERING_PART, + operation: 'mutate', + source: operationId, + expectedRevision: options?.expectedRevision, + mutate({ part }) { + const changed = mutate(local.abstractNumId, level); + if (!changed) return false; + syncNumberingToXmlTree(part, getConverterNumbering(editor)); + return true; + }, + }); + + // The mutation itself must have changed something — a clone alone is not enough. + if (!result.changed) { + noOp = true; + return false; // compoundMutation rolls back the clone + } + + // Apply rebinding + re-render in a single PM transaction + const { tr } = editor.state; + applyPendingRebind(editor, tr, local); + dispatchEditorTransaction(editor, tr); + if (local.pendingRebind) clearIndexCache(editor); + return true; + }, + }); + + if (noOp) { + return toListsFailure('NO_OP', `${operationId}: values already match.`, { target }); + } + if (!compound.success) { + return toListsFailure('NO_OP', `${operationId}: mutation failed.`, { target }); + } + + return { success: true, item: targetResult.resolved.address }; +} + +// --------------------------------------------------------------------------- +// SD-2025 — New user-facing wrappers +// --------------------------------------------------------------------------- + +export function listsGetStyleWrapper(editor: Editor, input: ListsGetStyleInput): ListsGetStyleResult { + const levelsError = validateLevelsArray(input.levels); + if (levelsError) return levelsError; + + const normalized = normalizeLevels(input.levels); + + const targetResult = resolveTargetAbstract(editor, input.target); + if (!targetResult.ok) return (targetResult as TargetAbstractFailure).failure; + + const style = LevelFormattingHelpers.captureEffectiveStyle( + editor, + targetResult.abstractNumId, + targetResult.numId, + normalized, + ) as ListTemplate | null; + + if (!style) { + return toListsFailure('INVALID_TARGET', 'Could not capture style from target.', { target: input.target }); + } + + return { success: true, style }; +} + +export function listsApplyStyleWrapper( + editor: Editor, + input: ListsApplyStyleInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.applyStyle', options); + + if (input.style.version !== 1) { + return toListsFailure('INVALID_INPUT', 'Unsupported style version.', { version: input.style.version }); + } + + const levelsError = validateLevelsArray(input.levels); + if (levelsError) return levelsError; + + const normalized = normalizeLevels(input.levels); + + const targetResult = resolveTargetAbstract(editor, input.target); + if (!targetResult.ok) return (targetResult as TargetAbstractFailure).failure; + + const preflightError = preflightTemplateLevels(input.style, normalized, input.target); + if (preflightError) return preflightError; + + if (options?.dryRun) { + return { success: true, item: targetResult.resolved.address }; + } + + let applyError: string | undefined; + let noOp = false; + + const compound = compoundMutation({ + editor, + source: 'lists.applyStyle', + affectedParts: [NUMBERING_PART], + execute() { + const resolved = resolveListItem(editor, input.target); + const local = ensureSequenceLocalAbstract(editor, resolved, targetResult.abstractNumId, targetResult.numId); + + const result = mutatePart({ + editor, + partId: NUMBERING_PART, + operation: 'mutate', + source: 'lists.applyStyle', + expectedRevision: options?.expectedRevision, + mutate({ part }) { + const applyResult = LevelFormattingHelpers.applyTemplateToAbstract( + editor, + local.abstractNumId, + input.style, + normalized, + ) as { changed: boolean; error?: string }; + if (applyResult.error) { + applyError = applyResult.error; + return false; + } + + // Clear lvlOverride formatting on affected levels so overrides + // don't shadow the newly applied abstract values. + let overridesCleared = false; + const affectedLevels = normalized ?? input.style.levels.map((l) => l.level); + for (const ilvl of affectedLevels) { + overridesCleared = LevelFormattingHelpers.clearLevelOverride(editor, local.numId, ilvl) || overridesCleared; + } + + if (!applyResult.changed && !overridesCleared) return false; + syncNumberingToXmlTree(part, getConverterNumbering(editor)); + return true; + }, + }); + + if (applyError || !result.changed) { + noOp = !applyError; + return false; // compoundMutation rolls back the clone + } + + const { tr } = editor.state; + applyPendingRebind(editor, tr, local); + dispatchEditorTransaction(editor, tr); + if (local.pendingRebind) clearIndexCache(editor); + return true; + }, + }); + + if (applyError) return toApplyTemplateError(applyError, input.target); + if (noOp) return toListsFailure('NO_OP', 'All style levels already match.', { target: input.target }); + if (!compound.success) return toListsFailure('NO_OP', 'Style application failed.', { target: input.target }); + + return { success: true, item: targetResult.resolved.address }; +} + +type SetValueInput = { target: { kind: 'block'; nodeType: 'listItem'; nodeId: string }; value: number | null }; +type SetValueDelegate = (editor: Editor, input: SetValueInput, options?: MutationOptions) => ListsMutateItemResult; + +let _setValueDelegate: SetValueDelegate | undefined; + +/** Register the setValue delegate so restartAt can reuse its separation + override logic. */ +export function registerSetValueDelegate(fn: SetValueDelegate): void { + _setValueDelegate = fn; +} + +export function listsRestartAtWrapper( + editor: Editor, + input: ListsRestartAtInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.restartAt', options); + + if (input.startAt < 1 || !Number.isInteger(input.startAt)) { + return toListsFailure('INVALID_INPUT', 'startAt must be a positive integer (>= 1).', { startAt: input.startAt }); + } + + const resolved = resolveListItem(editor, input.target); + if (resolved.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); + } + + // Reject bullet sequences + if (resolved.kind === 'bullet') { + return toListsFailure('INVALID_INPUT', 'Cannot restart numbering on a bullet list.', { target: input.target }); + } + + if (!_setValueDelegate) { + return toListsFailure('INVALID_INPUT', 'restartAt: internal delegate not registered.', { target: input.target }); + } + + // Delegate to the existing setValue wrapper which handles both + // first-in-sequence and mid-sequence (auto-separate + startOverride) + return _setValueDelegate(editor, { target: input.target, value: input.startAt }, options); +} + +export function listsSetLevelNumberStyleWrapper( + editor: Editor, + input: ListsSetLevelNumberStyleInput, + options?: MutationOptions, +): ListsMutateItemResult { + // Reject 'bullet' — must use setLevelBullet or setLevelPictureBullet + if (input.numberStyle === 'bullet') { + return toListsFailure('INVALID_INPUT', 'Use setLevelBullet or setLevelPictureBullet to set bullet format.', { + numberStyle: input.numberStyle, + }); + } + + return executeSequenceLocalLevelMutation( + editor, + 'lists.setLevelNumberStyle', + input.target, + input.level, + options, + (abstractNumId, ilvl) => LevelFormattingHelpers.setLevelNumberStyle(editor, abstractNumId, ilvl, input.numberStyle), + ); +} + +export function listsSetLevelTextWrapper( + editor: Editor, + input: ListsSetLevelTextInput, + options?: MutationOptions, +): ListsMutateItemResult { + return executeSequenceLocalLevelMutation( + editor, + 'lists.setLevelText', + input.target, + input.level, + options, + (abstractNumId, ilvl) => LevelFormattingHelpers.setLevelText(editor, abstractNumId, ilvl, input.text), + ); +} + +export function listsSetLevelStartWrapper( + editor: Editor, + input: ListsSetLevelStartInput, + options?: MutationOptions, +): ListsMutateItemResult { + if (input.startAt < 1 || !Number.isInteger(input.startAt)) { + return toListsFailure('INVALID_INPUT', 'startAt must be a positive integer (>= 1).', { startAt: input.startAt }); + } + + // Pre-check: reject bullet levels before entering the shared mutation path + const levelError = validateLevel(input.level); + if (!levelError) { + const targetResult = resolveTargetAbstract(editor, input.target); + if (targetResult.ok && LevelFormattingHelpers.hasLevel(editor, targetResult.abstractNumId, input.level)) { + const abstracts = getConverterNumbering(editor).abstracts as Record; + const lvlEl = LevelFormattingHelpers.findLevelElement(abstracts[targetResult.abstractNumId], input.level); + const props = LevelFormattingHelpers.readLevelProperties(lvlEl, input.level); + if (props.numFmt === 'bullet') { + return toListsFailure('INVALID_INPUT', 'Cannot set start value on a bullet level.', { + target: input.target, + level: input.level, + }); + } + } + } + + return executeSequenceLocalLevelMutation( + editor, + 'lists.setLevelStart', + input.target, + input.level, + options, + (abstractNumId, ilvl) => LevelFormattingHelpers.setLevelStart(editor, abstractNumId, ilvl, input.startAt), + ); +} + +export function listsSetLevelLayoutWrapper( + editor: Editor, + input: ListsSetLevelLayoutInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.setLevelLayout', options); + + const levelError = validateLevel(input.level); + if (levelError) return levelError; + + const targetResult = resolveTargetAbstract(editor, input.target); + if (!targetResult.ok) return (targetResult as TargetAbstractFailure).failure; + + if (!LevelFormattingHelpers.hasLevel(editor, targetResult.abstractNumId, input.level)) { + return toListsFailure('LEVEL_NOT_FOUND', `Level ${input.level} does not exist in the abstract definition.`, { + target: input.target, + level: input.level, + }); + } + + if (options?.dryRun) { + return { success: true, item: targetResult.resolved.address }; + } + + let noOp = false; + let layoutError: string | undefined; + + const compound = compoundMutation({ + editor, + source: 'lists.setLevelLayout', + affectedParts: [NUMBERING_PART], + execute() { + const resolved = resolveListItem(editor, input.target); + const local = ensureSequenceLocalAbstract(editor, resolved, targetResult.abstractNumId, targetResult.numId); + + const result = mutatePart({ + editor, + partId: NUMBERING_PART, + operation: 'mutate', + source: 'lists.setLevelLayout', + expectedRevision: options?.expectedRevision, + mutate({ part }) { + const layoutResult = LevelFormattingHelpers.setLevelLayout( + editor, + local.abstractNumId, + input.level, + input.layout, + ) as { changed: boolean; error?: string }; + + if (layoutResult.error) { + layoutError = layoutResult.error; + return false; + } + if (!layoutResult.changed) return false; + syncNumberingToXmlTree(part, getConverterNumbering(editor)); + return true; + }, + }); + + if (layoutError || !result.changed) { + noOp = !layoutError; + return false; + } + + const { tr } = editor.state; + applyPendingRebind(editor, tr, local); + dispatchEditorTransaction(editor, tr); + if (local.pendingRebind) clearIndexCache(editor); + return true; + }, + }); + + if (layoutError) { + return toListsFailure('INVALID_INPUT', `Layout mutation failed: ${layoutError}.`, { target: input.target }); + } + if (noOp) return toListsFailure('NO_OP', 'lists.setLevelLayout: values already match.', { target: input.target }); + if (!compound.success) + return toListsFailure('NO_OP', 'lists.setLevelLayout: mutation failed.', { target: input.target }); + + return { success: true, item: targetResult.resolved.address }; +} + +// --------------------------------------------------------------------------- +// Existing wrappers (clearLevelOverrides) +// --------------------------------------------------------------------------- + export function listsClearLevelOverridesWrapper( editor: Editor, input: ListsClearLevelOverridesInput, diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts index d777278edb..df6bb3937e 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts @@ -37,6 +37,8 @@ import type { ListsSetLevelRestartInput, ListsConvertToTextInput, ListsConvertToTextResult, + ListTemplate, + ListPresetId, MutationOptions, ReceiptFailureCode, PlanReceipt, @@ -57,6 +59,7 @@ import { resolveBlock, resolveBlocksInRange, getAbstractNumId, + getAllListItemProjections, getContiguousSequence, getSequenceFromTarget, isFirstInSequence, @@ -67,7 +70,11 @@ import { evaluateCanContinuePrevious, } from '../helpers/list-sequence-helpers.js'; import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; +import { LevelFormattingHelpers } from '../../core/helpers/list-level-formatting-helpers.js'; import { updateNumberingProperties } from '../../core/commands/changeListLevel.js'; +import { syncNumberingToXmlTree } from '../../core/parts/adapters/numbering-part-descriptor.js'; +import { getPart } from '../../core/parts/store/part-store.js'; +import type { PartId } from '../../core/parts/types.js'; // --------------------------------------------------------------------------- // Command types @@ -398,6 +405,69 @@ export function listsOutdentWrapper( // New SD-1272 mutations // --------------------------------------------------------------------------- +/** Ordered presets map to 'ordered' kind, bullet presets to 'bullet'. */ +const PRESET_KIND_MAP: Record = { + decimal: 'ordered', + decimalParenthesis: 'ordered', + lowerLetter: 'ordered', + upperLetter: 'ordered', + lowerRoman: 'ordered', + upperRoman: 'ordered', + disc: 'bullet', + circle: 'bullet', + square: 'bullet', + dash: 'bullet', +}; + +/** + * Resolve the effective list kind from the create input. + * Returns the kind or a failure result. + */ +function resolveCreateKind(input: ListsCreateInput): { kind: 'ordered' | 'bullet' } | { failure: ListsCreateResult } { + const raw = input as Record; + + // style and preset cannot both be provided + if (raw.style != null && raw.preset != null) { + return { failure: toListsFailure('INVALID_INPUT', 'Cannot provide both style and preset.', {}) }; + } + + if (raw.preset != null) { + const presetKind = PRESET_KIND_MAP[raw.preset as string]; + if (!presetKind) { + return { failure: toListsFailure('INVALID_INPUT', `Unknown preset: ${raw.preset}.`, { preset: raw.preset }) }; + } + if (raw.kind != null && raw.kind !== presetKind) { + return { + failure: toListsFailure( + 'INVALID_INPUT', + `Preset kind (${presetKind}) conflicts with provided kind (${raw.kind}).`, + { + preset: raw.preset, + kind: raw.kind, + }, + ), + }; + } + return { kind: presetKind }; + } + + if (raw.style != null) { + // When style is provided, kind is required + if (raw.kind == null) { + return { failure: toListsFailure('INVALID_INPUT', 'kind is required when style is provided.', {}) }; + } + return { kind: raw.kind as 'ordered' | 'bullet' }; + } + + // Neither style nor preset — kind is required + if (raw.kind == null) { + return { + failure: toListsFailure('INVALID_INPUT', 'kind is required when neither preset nor style is provided.', {}), + }; + } + return { kind: raw.kind as 'ordered' | 'bullet' }; +} + export function listsCreateWrapper( editor: Editor, input: ListsCreateInput, @@ -422,54 +492,42 @@ export function listsCreateWrapper( return toListsFailure('LEVEL_OUT_OF_RANGE', 'Level must be between 0 and 8.', { level }); } - const listType = input.kind === 'ordered' ? 'orderedList' : 'bulletList'; + // Resolve kind (may fail with INVALID_INPUT) + const kindResult = resolveCreateKind(input); + if ('failure' in kindResult) return kindResult.failure; + const { kind } = kindResult; - if (input.mode === 'empty') { - const block = resolveBlock(editor, input.at.nodeId); - if (block.nodeType === 'listItem') { - return toListsFailure('INVALID_TARGET', 'Target paragraph is already a list item.', { target: input.at }); - } + const listType = kind === 'ordered' ? 'orderedList' : 'bulletList'; - if (options?.dryRun) { - return { success: true, listId: '(dry-run)', item: { kind: 'block', nodeType: 'listItem', nodeId: '(dry-run)' } }; + // Resolve style template to apply (if any) + let styleTemplate: ListTemplate | undefined; + if (raw.style != null) { + styleTemplate = raw.style as ListTemplate; + if (styleTemplate.version !== 1) { + return toListsFailure('INVALID_INPUT', 'Unsupported style version.', { version: styleTemplate.version }); } - - let numId: number | undefined; - const receipt = executeDomainCommandWithRollback( - editor, - () => { - numId = ListHelpers.getNewListId(editor); - ListHelpers.generateNewListDefinition({ numId, listType, editor }); - const { tr } = editor.state; - updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr); - dispatchEditorTransaction(editor, tr); - clearIndexCache(editor); - return true; - }, - { expectedRevision: options?.expectedRevision }, - ); - - if (receipt.steps[0]?.effect !== 'changed') { - return toListsFailure('INVALID_TARGET', 'List creation could not be applied.', { mode: input.mode }); + } else if (raw.preset != null) { + styleTemplate = LevelFormattingHelpers.getPresetTemplate(raw.preset as string) as ListTemplate | undefined; + if (!styleTemplate) { + return toListsFailure('INVALID_INPUT', `Unknown preset: ${raw.preset}.`, { preset: raw.preset }); } - - return { - success: true, - listId: `${numId!}:${block.nodeId}`, - item: { kind: 'block', nodeType: 'listItem', nodeId: block.nodeId }, - }; } - // mode: 'fromParagraphs' - const targets = isBlockRange(input.target) - ? resolveBlocksInRange(editor, input.target.from.nodeId, input.target.to.nodeId) - : [resolveBlock(editor, input.target.nodeId)]; + // Resolve target blocks — narrowing via the mode discriminant + let blocks: ReturnType[]; + if (input.mode === 'empty') { + blocks = [resolveBlock(editor, input.at.nodeId)]; + } else { + blocks = isBlockRange(input.target) + ? resolveBlocksInRange(editor, input.target.from.nodeId, input.target.to.nodeId) + : [resolveBlock(editor, input.target.nodeId)]; + } - if (targets.length === 0) { - return toListsFailure('INVALID_TARGET', 'No paragraphs found in the specified range.', { target: input.target }); + if (blocks.length === 0) { + return toListsFailure('INVALID_TARGET', 'No paragraphs found in the specified range.', {}); } - const alreadyListItem = targets.find((t) => t.nodeType === 'listItem'); + const alreadyListItem = blocks.find((t) => t.nodeType === 'listItem'); if (alreadyListItem) { return toListsFailure('INVALID_TARGET', 'One or more target paragraphs are already list items.', { nodeId: alreadyListItem.nodeId, @@ -480,22 +538,93 @@ export function listsCreateWrapper( return { success: true, listId: '(dry-run)', - item: { kind: 'block', nodeType: 'listItem', nodeId: targets[0]!.nodeId }, + item: { kind: 'block', nodeType: 'listItem', nodeId: blocks[0]!.nodeId }, }; } + // Sequence mode resolution + const sequenceInput = (raw.sequence as { mode: string; startAt?: number } | undefined) ?? { mode: 'new' }; + + // Pre-flight continuePrevious compatibility BEFORE any mutations. + // continuePrevious binds the new paragraphs to an existing sequence's + // numId — applying a different style/preset is contradictory since the + // formatting comes from the previous sequence's definition. + let continuePreviousNumId: number | undefined; + if (sequenceInput.mode === 'continuePrevious') { + if (styleTemplate) { + return toListsFailure( + 'INVALID_INPUT', + 'preset/style cannot be combined with sequence.mode "continuePrevious". ' + + 'The new items inherit formatting from the previous sequence.', + {}, + ); + } + + const allItems = getAllListItemProjections(editor); + const firstBlockPos = blocks[0]!.pos; + for (let i = allItems.length - 1; i >= 0; i--) { + const item = allItems[i]!; + if (item.candidate.pos >= firstBlockPos) continue; + if (item.numId == null) continue; + if (item.kind === kind) { + continuePreviousNumId = item.numId; + break; + } + } + if (continuePreviousNumId == null) { + return toListsFailure('NO_COMPATIBLE_PREVIOUS', 'No compatible previous list sequence found.', {}); + } + } + let numId: number | undefined; + const receipt = executeDomainCommandWithRollback( editor, () => { - numId = ListHelpers.getNewListId(editor); - ListHelpers.generateNewListDefinition({ numId, listType, editor }); - const { tr } = editor.state; - for (const block of targets) { - updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr); + if (sequenceInput.mode === 'continuePrevious') { + // Use the previous sequence's numId directly — no fresh allocation, + // no orphan definitions. Style/preset is NOT applied to the previous + // sequence (the plan says preset/style applies to the new list only, + // not as a constraint on the previous sequence's formatting). + numId = continuePreviousNumId!; + + const { tr } = editor.state; + for (const block of blocks) { + updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + } else { + // mode: 'new' — allocate a fresh definition + numId = ListHelpers.getNewListId(editor); + ListHelpers.generateNewListDefinition({ numId, listType, editor }); + + // Apply style/preset template if provided + if (styleTemplate) { + const abstractNumId = getAbstractNumId(editor, numId!); + if (abstractNumId != null) { + LevelFormattingHelpers.applyTemplateToAbstract(editor, abstractNumId, styleTemplate, undefined); + const numberingPart = getPart(editor, 'word/numbering.xml' as PartId); + if (numberingPart) { + const converter = (editor as unknown as { converter: { numbering: Record } }).converter; + syncNumberingToXmlTree(numberingPart, converter.numbering); + } + } + } + + // Convert paragraphs to list items + const { tr } = editor.state; + for (const block of blocks) { + updateNumberingProperties({ numId: numId!, ilvl: level }, block.node, block.pos, editor, tr); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + + if (sequenceInput.startAt != null) { + ListHelpers.setLvlOverride(editor, numId!, level, { startOverride: sequenceInput.startAt }); + } } - dispatchEditorTransaction(editor, tr); - clearIndexCache(editor); + return true; }, { expectedRevision: options?.expectedRevision }, @@ -507,8 +636,8 @@ export function listsCreateWrapper( return { success: true, - listId: `${numId!}:${targets[0]!.nodeId}`, - item: { kind: 'block', nodeType: 'listItem', nodeId: targets[0]!.nodeId }, + listId: `${numId!}:${blocks[0]!.nodeId}`, + item: { kind: 'block', nodeType: 'listItem', nodeId: blocks[0]!.nodeId }, }; } diff --git a/tests/doc-api-stories/tests/lists/all-commands.ts b/tests/doc-api-stories/tests/lists/all-commands.ts index 03331cb4eb..973765461b 100644 --- a/tests/doc-api-stories/tests/lists/all-commands.ts +++ b/tests/doc-api-stories/tests/lists/all-commands.ts @@ -33,6 +33,15 @@ const ALL_LISTS_COMMAND_IDS = [ 'lists.setLevelTrailingCharacter', 'lists.setLevelMarkerFont', 'lists.clearLevelOverrides', + 'lists.setType', + // SD-2025 user-facing operations + 'lists.getStyle', + 'lists.applyStyle', + 'lists.restartAt', + 'lists.setLevelNumberStyle', + 'lists.setLevelText', + 'lists.setLevelStart', + 'lists.setLevelLayout', ] as const; type ListsCommandId = (typeof ALL_LISTS_COMMAND_IDS)[number]; @@ -68,6 +77,7 @@ describe('document-api story: all lists commands', () => { 'lists.canJoin', 'lists.canContinuePrevious', 'lists.captureTemplate', + 'lists.getStyle', ]); function slug(operationId: ListsCommandId): string { @@ -132,6 +142,14 @@ describe('document-api story: all lists commands', () => { return; } + if (operationId === 'lists.getStyle') { + expect(result?.success).toBe(true); + expect(result?.style?.version).toBe(1); + expect(Array.isArray(result?.style?.levels)).toBe(true); + expect(result?.style?.levels?.length).toBeGreaterThan(0); + return; + } + throw new Error(`Unexpected read assertion branch for ${operationId}.`); } @@ -150,6 +168,24 @@ describe('document-api story: all lists commands', () => { return unwrap(unwrap(envelope?.data)); } + async function getStyle(docPath: string, target: ListItemAddress): Promise { + const result = await callDocOperation('lists.getStyle', { doc: docPath, target }); + expect(result?.success).toBe(true); + expect(result?.style?.version).toBe(1); + return result.style; + } + + async function getListItem(docPath: string, address: ListItemAddress): Promise { + const result = await callDocOperation('lists.get', { doc: docPath, address }); + return result?.item ?? result; + } + + function requireStyleLevel(style: any, level: number): any { + const match = style?.levels?.find((entry: any) => entry?.level === level); + if (!match) throw new Error(`Style did not contain level ${level}.`); + return match; + } + // --------------------------------------------------------------------------- // Fixture helpers // @@ -496,6 +532,17 @@ describe('document-api story: all lists commands', () => { }); }, }, + { + operationId: 'lists.getStyle', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, _resultDoc, fixture) => { + const f = requireFixture('lists.getStyle', fixture); + return callDocOperation('lists.getStyle', { + doc: sourceDoc, + target: f.firstItem, + }); + }, + }, { operationId: 'lists.applyTemplate', prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), @@ -519,6 +566,70 @@ describe('document-api story: all lists commands', () => { }); }, }, + { + operationId: 'lists.applyStyle', + prepareSource: async (sourceDoc) => setupPreSeparatedFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.applyStyle', fixture); + const styleResult = await callDocOperation('lists.getStyle', { + doc: sourceDoc, + target: f.firstItem, + }); + const style = structuredClone(styleResult?.style); + const level0 = requireStyleLevel(style, 0); + level0.numFmt = 'upperRoman'; + level0.lvlText = '(%1)'; + + const result = await callDocOperation('lists.applyStyle', { + doc: sourceDoc, + out: resultDoc, + target: f.secondItem, + style, + }); + + const appliedStyle = await getStyle(resultDoc, f.secondItem); + const appliedLevel0 = requireStyleLevel(appliedStyle, 0); + expect(appliedLevel0.numFmt).toBe('upperRoman'); + expect(appliedLevel0.lvlText).toBe('(%1)'); + + const sourceStyle = await getStyle(resultDoc, f.firstItem); + expect(requireStyleLevel(sourceStyle, 0).numFmt).not.toBe('upperRoman'); + + return result; + }, + }, + { + operationId: 'lists.restartAt', + prepareSource: async (sourceDoc) => { + const fixture = await setupPreSeparatedFixture(sourceDoc); + const presetResult = await callDocOperation('lists.applyPreset', { + doc: sourceDoc, + out: sourceDoc, + target: fixture.secondItem, + preset: 'upperRoman', + }); + assertMutationSuccess('lists.applyPreset (prep)', presetResult); + return fixture; + }, + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.restartAt', fixture); + const result = await callDocOperation('lists.restartAt', { + doc: sourceDoc, + out: resultDoc, + target: f.secondItem, + startAt: 7, + }); + + const item = await getListItem(resultDoc, f.secondItem); + if (typeof item?.ordinal === 'number') { + expect(item.ordinal).toBe(7); + } else { + expect(String(item?.marker ?? '')).toContain('7'); + } + + return result; + }, + }, { operationId: 'lists.setLevelNumbering', prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), @@ -534,6 +645,89 @@ describe('document-api story: all lists commands', () => { }); }, }, + { + operationId: 'lists.setLevelNumberStyle', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelNumberStyle', fixture); + const result = await callDocOperation('lists.setLevelNumberStyle', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + numberStyle: 'upperRoman', + }); + + const level0 = requireStyleLevel(await getStyle(resultDoc, f.firstItem), 0); + expect(level0.numFmt).toBe('upperRoman'); + return result; + }, + }, + { + operationId: 'lists.setLevelText', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelText', fixture); + const result = await callDocOperation('lists.setLevelText', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + text: '(%1)', + }); + + const level0 = requireStyleLevel(await getStyle(resultDoc, f.firstItem), 0); + expect(level0.lvlText).toBe('(%1)'); + return result; + }, + }, + { + operationId: 'lists.setLevelStart', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelStart', fixture); + const result = await callDocOperation('lists.setLevelStart', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + startAt: 4, + }); + + const level0 = requireStyleLevel(await getStyle(resultDoc, f.firstItem), 0); + expect(level0.start).toBe(4); + return result; + }, + }, + { + operationId: 'lists.setLevelLayout', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelLayout', fixture); + const result = await callDocOperation('lists.setLevelLayout', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + layout: { + alignment: 'center', + alignedAt: 360, + textIndentAt: 1440, + followCharacter: 'tab', + tabStopAt: 1440, + }, + }); + + const level0 = requireStyleLevel(await getStyle(resultDoc, f.firstItem), 0); + expect(level0).toMatchObject({ + alignment: 'center', + indents: { left: 1440, hanging: 1080 }, + trailingCharacter: 'tab', + tabStopAt: 1440, + }); + return result; + }, + }, { operationId: 'lists.setLevelBullet', prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), @@ -605,6 +799,23 @@ describe('document-api story: all lists commands', () => { }); }, }, + { + operationId: 'lists.setType', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setType', fixture); + const result = await callDocOperation('lists.setType', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + kind: 'ordered', + }); + + const item = await getListItem(resultDoc, f.firstItem); + expect(item?.kind).toBe('ordered'); + return result; + }, + }, { operationId: 'lists.setLevelMarkerFont', prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), diff --git a/tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts b/tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts new file mode 100644 index 0000000000..1d2e0318db --- /dev/null +++ b/tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts @@ -0,0 +1,305 @@ +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '../../../..'); +const PRE_SEPARATED_FIXTURE = path.join(REPO_ROOT, 'packages/super-editor/src/tests/data/pre-separated-list.docx'); +const NUMBERING_PART = 'word/numbering.xml'; + +type ListItemAddress = { + kind: 'block'; + nodeType: 'listItem'; + nodeId: string; +}; + +type ListStyle = { + version: 1; + levels: Array<{ + level: number; + numFmt?: string; + lvlText?: string; + start?: number; + alignment?: string; + indents?: { left?: number; hanging?: number; firstLine?: number }; + trailingCharacter?: string; + markerFont?: string; + pictureBulletId?: number; + tabStopAt?: number | null; + }>; +}; + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function cloneStyle(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function requireLevel(style: ListStyle, level: number) { + const match = style.levels.find((entry) => entry.level === level); + if (!match) throw new Error(`Style did not contain level ${level}.`); + return match; +} + +describe('document-api story: lists style commands roundtrip', () => { + const { client, copyDoc, outPath, runCli } = useStoryHarness('lists/style-commands-roundtrip', { + preserveResults: true, + }); + + const api = client as any; + + async function callDocOperation(operationId: string, input: Record): Promise { + const segments = operationId.split('.'); + let fn: any = api.doc; + for (const segment of segments) fn = fn?.[segment]; + + if (typeof fn === 'function') { + return unwrap(await fn(input)); + } + + const normalizedInput = { ...input }; + if (typeof normalizedInput.out === 'string' && normalizedInput.out.length > 0 && normalizedInput.force == null) { + normalizedInput.force = true; + } + + const envelope = await runCli(['call', `doc.${operationId}`, '--input-json', JSON.stringify(normalizedInput)]); + return unwrap(unwrap(envelope?.data)); + } + + async function openFixture(label: string): Promise<{ sessionId: string; sourceDoc: string; resultDoc: string }> { + const sourceDoc = await copyDoc(PRE_SEPARATED_FIXTURE, `${label}-source.docx`); + const resultDoc = outPath(`${label}.docx`); + const sessionId = sid(label); + await callDocOperation('open', { sessionId, doc: sourceDoc }); + return { sessionId, sourceDoc, resultDoc }; + } + + async function saveResult(sessionId: string, resultDoc: string): Promise { + await callDocOperation('save', { sessionId, out: resultDoc, force: true }); + } + + async function listItems(sessionId: string): Promise { + const result = await callDocOperation('lists.list', { sessionId }); + return result?.items ?? []; + } + + async function resolvePreSeparatedFixture(sessionId: string): Promise<{ + firstItem: ListItemAddress; + secondItem: ListItemAddress; + }> { + const items = await listItems(sessionId); + if (items.length < 2) { + throw new Error(`Expected at least 2 list items, got ${items.length}.`); + } + + const firstListId = items[0].listId; + const secondListItem = items.find((item: any) => item.listId !== firstListId); + if (!secondListItem) { + throw new Error('Expected items from at least two different list sequences.'); + } + + return { + firstItem: items[0].address as ListItemAddress, + secondItem: secondListItem.address as ListItemAddress, + }; + } + + async function getStyle(sessionId: string, target: ListItemAddress): Promise { + const result = await callDocOperation('lists.getStyle', { sessionId, target }); + expect(result?.success).toBe(true); + expect(result?.style?.version).toBe(1); + return result.style as ListStyle; + } + + async function getItem(sessionId: string, address: ListItemAddress): Promise { + return callDocOperation('lists.get', { sessionId, address }); + } + + async function canContinuePrevious(sessionId: string, target: ListItemAddress): Promise { + return callDocOperation('lists.canContinuePrevious', { sessionId, target }); + } + + async function readZipEntry(docPath: string, zipPath: string): Promise { + const JSZipModule = await import('../../../../packages/superdoc/node_modules/jszip'); + const JSZip = JSZipModule.default; + const buffer = await readFile(docPath); + const zip = await JSZip.loadAsync(buffer); + const file = zip.file(zipPath); + return file ? file.async('string') : null; + } + + async function requireZipEntry(docPath: string, zipPath: string): Promise { + const content = await readZipEntry(docPath, zipPath); + if (content == null) { + throw new Error(`Missing zip entry "${zipPath}" in ${docPath}`); + } + return content; + } + + function assertMutationSuccess(operationId: string, result: any): void { + if (result?.success === true || result?.receipt?.success === true) return; + const code = result?.failure?.code ?? result?.receipt?.failure?.code ?? 'UNKNOWN'; + throw new Error(`${operationId} did not report success (code: ${code}).`); + } + + it('round-trips getStyle/applyStyle/restartAt without affecting the previous sequence', async () => { + const { sessionId, resultDoc } = await openFixture('style-apply-restart'); + const fixture = await resolvePreSeparatedFixture(sessionId); + + const canContinueBefore = await canContinuePrevious(sessionId, fixture.secondItem); + expect(canContinueBefore?.canContinue).toBe(true); + + const firstStyleBefore = await getStyle(sessionId, fixture.firstItem); + const secondStyleBefore = await getStyle(sessionId, fixture.secondItem); + + const editedStyle = cloneStyle(firstStyleBefore); + const level0 = requireLevel(editedStyle, 0); + level0.numFmt = 'upperRoman'; + level0.lvlText = '(%1)'; + level0.start = 4; + level0.alignment = 'center'; + level0.indents = { left: 1440, hanging: 1080 }; + level0.trailingCharacter = 'tab'; + level0.tabStopAt = 1440; + + const applyResult = await callDocOperation('lists.applyStyle', { + sessionId, + target: fixture.secondItem, + style: editedStyle, + }); + assertMutationSuccess('lists.applyStyle', applyResult); + + const secondStyleAfterApply = await getStyle(sessionId, fixture.secondItem); + const secondLevel0 = requireLevel(secondStyleAfterApply, 0); + expect(secondLevel0).toMatchObject({ + numFmt: 'upperRoman', + lvlText: '(%1)', + start: 4, + alignment: 'center', + indents: { left: 1440, hanging: 1080 }, + trailingCharacter: 'tab', + tabStopAt: 1440, + }); + + const firstStyleAfterApply = await getStyle(sessionId, fixture.firstItem); + expect(firstStyleAfterApply).toEqual(firstStyleBefore); + expect(secondStyleAfterApply).not.toEqual(secondStyleBefore); + + const canContinueAfterApply = await canContinuePrevious(sessionId, fixture.secondItem); + expect(canContinueAfterApply?.canContinue).toBe(false); + + const restartResult = await callDocOperation('lists.restartAt', { + sessionId, + target: fixture.secondItem, + startAt: 7, + }); + assertMutationSuccess('lists.restartAt', restartResult); + + const restartedItem = await getItem(sessionId, fixture.secondItem); + const restartedInfo = restartedItem?.item ?? restartedItem; + if (typeof restartedInfo?.ordinal === 'number') { + expect(restartedInfo.ordinal).toBe(7); + } else { + expect(String(restartedInfo?.marker ?? '')).toContain('7'); + } + + const secondStyleAfterRestart = await getStyle(sessionId, fixture.secondItem); + expect(secondStyleAfterRestart).toEqual(secondStyleAfterApply); + + await saveResult(sessionId, resultDoc); + + const numberingXml = await requireZipEntry(resultDoc, NUMBERING_PART); + expect(numberingXml).toContain('w:numFmt'); + expect(numberingXml).toContain('upperRoman'); + expect(numberingXml).toContain('w:lvlText'); + expect(numberingXml).toContain('(%1)'); + expect(numberingXml).toMatch(/w:lvlJc[^>]*w:val="center"/); + expect(numberingXml).toMatch( + /w:ind[^>]*w:left="1440"[^>]*w:hanging="1080"|w:ind[^>]*w:hanging="1080"[^>]*w:left="1440"/, + ); + expect(numberingXml).toMatch(/w:suff[^>]*w:val="tab"/); + expect(numberingXml).toMatch(/w:tab[^>]*w:val="num"[^>]*w:pos="1440"|w:tab[^>]*w:pos="1440"[^>]*w:val="num"/); + expect(numberingXml).toMatch(/w:startOverride[^>]*w:val="7"/); + }); + + it('applies decomposed setLevel* commands without clobbering prior level edits', async () => { + const { sessionId, resultDoc } = await openFixture('set-level-commands'); + const fixture = await resolvePreSeparatedFixture(sessionId); + + const baselineStyle = await getStyle(sessionId, fixture.firstItem); + const baselineLevel0 = requireLevel(baselineStyle, 0); + + const setNumberStyleResult = await callDocOperation('lists.setLevelNumberStyle', { + sessionId, + target: fixture.firstItem, + level: 0, + numberStyle: 'upperRoman', + }); + assertMutationSuccess('lists.setLevelNumberStyle', setNumberStyleResult); + + const afterNumberStyle = requireLevel(await getStyle(sessionId, fixture.firstItem), 0); + expect(afterNumberStyle.numFmt).toBe('upperRoman'); + expect(afterNumberStyle.lvlText).toBe(baselineLevel0.lvlText); + + const setTextResult = await callDocOperation('lists.setLevelText', { + sessionId, + target: fixture.firstItem, + level: 0, + text: '(%1)', + }); + assertMutationSuccess('lists.setLevelText', setTextResult); + + const afterText = requireLevel(await getStyle(sessionId, fixture.firstItem), 0); + expect(afterText.numFmt).toBe('upperRoman'); + expect(afterText.lvlText).toBe('(%1)'); + + const setStartResult = await callDocOperation('lists.setLevelStart', { + sessionId, + target: fixture.firstItem, + level: 0, + startAt: 4, + }); + assertMutationSuccess('lists.setLevelStart', setStartResult); + + const afterStart = requireLevel(await getStyle(sessionId, fixture.firstItem), 0); + expect(afterStart.numFmt).toBe('upperRoman'); + expect(afterStart.lvlText).toBe('(%1)'); + expect(afterStart.start).toBe(4); + + const setLayoutResult = await callDocOperation('lists.setLevelLayout', { + sessionId, + target: fixture.firstItem, + level: 0, + layout: { + alignment: 'center', + alignedAt: 360, + textIndentAt: 1440, + followCharacter: 'tab', + tabStopAt: 1440, + }, + }); + assertMutationSuccess('lists.setLevelLayout', setLayoutResult); + + const afterLayout = requireLevel(await getStyle(sessionId, fixture.firstItem), 0); + expect(afterLayout).toMatchObject({ + numFmt: 'upperRoman', + lvlText: '(%1)', + start: 4, + alignment: 'center', + indents: { left: 1440, hanging: 1080 }, + trailingCharacter: 'tab', + tabStopAt: 1440, + }); + + await saveResult(sessionId, resultDoc); + + const numberingXml = await requireZipEntry(resultDoc, NUMBERING_PART); + expect(numberingXml).toContain('upperRoman'); + expect(numberingXml).toContain('(%1)'); + expect(numberingXml).toMatch(/w:start[^>]*w:val="4"/); + expect(numberingXml).toMatch(/w:lvlJc[^>]*w:val="center"/); + expect(numberingXml).toMatch(/w:suff[^>]*w:val="tab"/); + }); +}); From 8ccb99e0e6ad96ec1ec761932f79d36122ba7525 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 18:06:12 -0700 Subject: [PATCH 2/3] chore: fix type --- .../plan-engine/lists-formatting-wrappers.ts | 11 +++++------ .../plan-engine/lists-wrappers.ts | 13 +++++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts index 6ba3ff565c..8954a08592 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts @@ -187,14 +187,13 @@ function resolveTargetAbstract( // --------------------------------------------------------------------------- const NUMBERING_PART: PartId = 'word/numbering.xml'; +type NumberingModel = Parameters[1]; +type NumberingTransaction = Parameters[4]; -function getConverterNumbering(editor: Editor): { - abstracts: Record; - definitions: Record; -} { +function getConverterNumbering(editor: Editor): NumberingModel { return ( editor as unknown as { - converter?: { numbering: { abstracts: Record; definitions: Record } }; + converter?: { numbering: NumberingModel }; } ).converter!.numbering; } @@ -859,7 +858,7 @@ function ensureSequenceLocalAbstract( * Apply pending rebind operations to a PM transaction. * Must be called within the same transaction as the actual mutation. */ -function applyPendingRebind(editor: Editor, tr: unknown, local: SequenceLocalResult): void { +function applyPendingRebind(editor: Editor, tr: NumberingTransaction, local: SequenceLocalResult): void { if (!local.pendingRebind) return; for (const item of local.pendingRebind) { updateNumberingProperties( diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts index df6bb3937e..33b664fd99 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts @@ -114,6 +114,16 @@ function dispatchEditorTransaction(editor: Editor, tr: unknown): void { ); } +type NumberingModel = Parameters[1]; + +function getConverterNumbering(editor: Editor): NumberingModel { + return ( + editor as unknown as { + converter?: { numbering: NumberingModel }; + } + ).converter!.numbering; +} + /** * Execute a domain command with automatic numbering rollback. * @@ -606,8 +616,7 @@ export function listsCreateWrapper( LevelFormattingHelpers.applyTemplateToAbstract(editor, abstractNumId, styleTemplate, undefined); const numberingPart = getPart(editor, 'word/numbering.xml' as PartId); if (numberingPart) { - const converter = (editor as unknown as { converter: { numbering: Record } }).converter; - syncNumberingToXmlTree(numberingPart, converter.numbering); + syncNumberingToXmlTree(numberingPart, getConverterNumbering(editor)); } } } From 153f804028c811e1149cfe231b05cb174cbd9d72 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 19:43:45 -0700 Subject: [PATCH 3/3] fix: sequence-local list style edits and add restart regression coverage --- .../helpers/list-level-formatting-helpers.js | 265 +++++++++++++++--- .../list-level-formatting-helpers.test.js | 164 +++++++++++ .../lists-formatting-wrappers.test.ts | 140 ++++++++- .../plan-engine/lists-formatting-wrappers.ts | 55 ++-- .../plan-engine/lists-wrappers.test.ts | 37 +++ .../plan-engine/lists-wrappers.ts | 36 ++- .../tests/lists/style-commands-roundtrip.ts | 40 +++ 7 files changed, 666 insertions(+), 71 deletions(-) diff --git a/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js index 70a8bffd93..ff21ecb2e1 100644 --- a/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js +++ b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js @@ -314,6 +314,45 @@ function setLevelStart(editor, abstractNumId, ilvl, start) { return setChildAttr(resolved.lvlEl, 'w:start', String(start)); } +/** + * Apply a partial level-style object to a raw `w:lvl` element. + * This preserves unspecified properties already present on the level. + * + * @param {Object} lvlEl + * @param {Object} entry + * @returns {boolean} + */ +function applyLevelPropertiesToElement(lvlEl, entry) { + let changed = false; + + if (entry.numFmt != null || entry.lvlText != null) { + const fmtParams = {}; + if (entry.numFmt != null) fmtParams.numFmt = entry.numFmt; + if (entry.lvlText != null) fmtParams.lvlText = entry.lvlText; + if (entry.start != null) fmtParams.start = entry.start; + + if (fmtParams.numFmt != null && fmtParams.lvlText != null) { + changed = mutateLevelNumberingFormat(lvlEl, fmtParams) || changed; + } else { + if (fmtParams.numFmt != null) changed = setChildAttr(lvlEl, 'w:numFmt', fmtParams.numFmt) || changed; + if (fmtParams.lvlText != null) changed = setChildAttr(lvlEl, 'w:lvlText', fmtParams.lvlText) || changed; + if (fmtParams.start != null) changed = setChildAttr(lvlEl, 'w:start', String(fmtParams.start)) || changed; + } + } else if (entry.start != null) { + changed = setChildAttr(lvlEl, 'w:start', String(entry.start)) || changed; + } + + if (entry.alignment != null) changed = mutateLevelAlignment(lvlEl, entry.alignment) || changed; + if (entry.indents != null) changed = mutateLevelIndents(lvlEl, entry.indents) || changed; + if (entry.trailingCharacter != null) + changed = mutateLevelTrailingCharacter(lvlEl, entry.trailingCharacter) || changed; + if (entry.markerFont != null) changed = mutateLevelMarkerFont(lvlEl, entry.markerFont) || changed; + if (entry.pictureBulletId != null) changed = mutateLevelPictureBulletId(lvlEl, entry.pictureBulletId) || changed; + if (entry.tabStopAt !== undefined) changed = mutateLevelTabStop(lvlEl, entry.tabStopAt) || changed; + + return changed; +} + // ────────────────────────────────────────────────────────────────────────────── // Raw XML Mutators (no sync, no emit) // ────────────────────────────────────────────────────────────────────────────── @@ -583,6 +622,60 @@ function clearLevelOverride(editor, numId, ilvl) { return true; } +/** + * Fold formatting from `w:lvlOverride/w:lvl` into the target abstract level, + * then remove only the `w:lvl` child while preserving any `w:startOverride`. + * + * This lets sequence-local style edits operate on the effective visible style + * without dropping restart state stored on the numbering instance. + * + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} numId + * @param {number} ilvl + * @returns {boolean} + */ +function materializeLevelFormattingOverride(editor, abstractNumId, numId, ilvl) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + const numDef = editor.converter.numbering?.definitions?.[numId]; + if (!resolved || !numDef?.elements) return false; + + const ilvlStr = String(ilvl); + const overrideIndex = numDef.elements.findIndex( + (el) => el.name === 'w:lvlOverride' && el.attributes?.['w:ilvl'] === ilvlStr, + ); + if (overrideIndex === -1) return false; + + const overrideEl = numDef.elements[overrideIndex]; + if (!overrideEl?.elements) return false; + + const lvlIndex = overrideEl.elements.findIndex((el) => el.name === 'w:lvl'); + if (lvlIndex === -1) return false; + + const lvlEl = overrideEl.elements[lvlIndex]; + const props = readLevelProperties(lvlEl, ilvl); + const abstractChanged = applyLevelPropertiesToElement(resolved.lvlEl, props); + const lvlRestartElements = + lvlEl.elements?.filter((el) => el.name === 'w:lvlRestart').map((el) => deepCloneElement(el)) ?? []; + + overrideEl.elements.splice(lvlIndex, 1); + if (lvlRestartElements.length > 0) { + overrideEl.elements.push({ + type: 'element', + name: 'w:lvl', + attributes: { 'w:ilvl': ilvlStr }, + elements: lvlRestartElements, + }); + } + + let overrideChanged = true; + if (overrideEl.elements.length === 0) { + numDef.elements.splice(overrideIndex, 1); + } + + return abstractChanged || overrideChanged; +} + // ────────────────────────────────────────────────────────────────────────────── // Template Capture // ────────────────────────────────────────────────────────────────────────────── @@ -644,36 +737,7 @@ function applyTemplateToAbstract(editor, abstractNumId, template, levels) { for (const ilvl of targetLevels) { const entry = templateByLevel.get(ilvl); const lvlEl = findLevelElement(abstract, ilvl); - - if (entry.numFmt != null || entry.lvlText != null) { - const fmtParams = {}; - if (entry.numFmt != null) fmtParams.numFmt = entry.numFmt; - if (entry.lvlText != null) fmtParams.lvlText = entry.lvlText; - if (entry.start != null) fmtParams.start = entry.start; - - if (fmtParams.numFmt != null && fmtParams.lvlText != null) { - anyChanged = mutateLevelNumberingFormat(lvlEl, fmtParams) || anyChanged; - } else { - if (fmtParams.numFmt != null) anyChanged = setChildAttr(lvlEl, 'w:numFmt', fmtParams.numFmt) || anyChanged; - if (fmtParams.lvlText != null) anyChanged = setChildAttr(lvlEl, 'w:lvlText', fmtParams.lvlText) || anyChanged; - if (fmtParams.start != null) anyChanged = setChildAttr(lvlEl, 'w:start', String(fmtParams.start)) || anyChanged; - } - } else if (entry.start != null) { - anyChanged = setChildAttr(lvlEl, 'w:start', String(entry.start)) || anyChanged; - } - - if (entry.alignment != null) anyChanged = mutateLevelAlignment(lvlEl, entry.alignment) || anyChanged; - if (entry.indents != null) anyChanged = mutateLevelIndents(lvlEl, entry.indents) || anyChanged; - if (entry.trailingCharacter != null) - anyChanged = mutateLevelTrailingCharacter(lvlEl, entry.trailingCharacter) || anyChanged; - if (entry.markerFont != null) anyChanged = mutateLevelMarkerFont(lvlEl, entry.markerFont) || anyChanged; - if (entry.pictureBulletId != null) - anyChanged = mutateLevelPictureBulletId(lvlEl, entry.pictureBulletId) || anyChanged; - - // Apply tabStopAt if present in template - if (entry.tabStopAt !== undefined) { - anyChanged = mutateLevelTabStop(lvlEl, entry.tabStopAt) || anyChanged; - } + anyChanged = applyLevelPropertiesToElement(lvlEl, entry) || anyChanged; } return { changed: anyChanged }; @@ -974,27 +1038,149 @@ function deepCloneElement(element) { } /** - * Clone an abstract definition and create a new w:num pointing to it. - * Returns the new abstractNumId and numId. + * Clone an abstract definition and return the new abstractNumId. * * @param {import('../Editor').Editor} editor * @param {number} originalAbstractNumId - * @param {number} originalNumId - * @returns {{ newAbstractNumId: number, newNumId: number }} + * @returns {{ newAbstractNumId: number }} */ -function cloneAbstractAndNum(editor, originalAbstractNumId, originalNumId) { +function cloneAbstractDefinition(editor, originalAbstractNumId) { const numbering = editor.converter.numbering; - - // Find next available abstractNumId const existingAbstractIds = Object.keys(numbering.abstracts).map(Number); const newAbstractNumId = existingAbstractIds.length > 0 ? Math.max(...existingAbstractIds) + 1 : 0; - // Clone the abstract definition const original = numbering.abstracts[originalAbstractNumId]; + if (!original) { + throw new Error(`cloneAbstractDefinition: abstract ${originalAbstractNumId} not found.`); + } + const cloned = deepCloneElement(original); cloned.attributes = { ...cloned.attributes, 'w:abstractNumId': String(newAbstractNumId) }; numbering.abstracts[newAbstractNumId] = cloned; + return { newAbstractNumId }; +} + +/** + * Clone an abstract definition and retarget an existing w:num to it. + * Preserves any lvlOverride/startOverride state on the num definition. + * + * @param {import('../Editor').Editor} editor + * @param {number} originalAbstractNumId + * @param {number} numId + * @returns {{ newAbstractNumId: number }} + */ +function cloneAbstractIntoNum(editor, originalAbstractNumId, numId) { + const numbering = editor.converter.numbering; + const { newAbstractNumId } = cloneAbstractDefinition(editor, originalAbstractNumId); + + const numDef = numbering.definitions[numId]; + if (!numDef) { + throw new Error(`cloneAbstractIntoNum: num ${numId} not found.`); + } + if (!numDef.elements) numDef.elements = []; + + const abstractNumIdEl = numDef.elements.find((el) => el.name === 'w:abstractNumId'); + if (abstractNumIdEl) { + abstractNumIdEl.attributes = { ...(abstractNumIdEl.attributes || {}), 'w:val': String(newAbstractNumId) }; + } else { + numDef.elements.unshift({ + type: 'element', + name: 'w:abstractNumId', + attributes: { 'w:val': String(newAbstractNumId) }, + }); + } + + return { newAbstractNumId }; +} + +/** + * Copy sequence-state overrides (startOverride and instance lvlRestart) from + * one num definition to another, intentionally excluding formatting overrides. + * + * @param {import('../Editor').Editor} editor + * @param {number} fromNumId + * @param {number} toNumId + * @param {number[] | undefined} levels + * @returns {boolean} + */ +function copySequenceStateOverrides(editor, fromNumId, toNumId, levels) { + if (fromNumId === toNumId) return false; + + const sourceNumDef = editor.converter.numbering?.definitions?.[fromNumId]; + const targetNumDef = editor.converter.numbering?.definitions?.[toNumId]; + if (!sourceNumDef?.elements || !targetNumDef) return false; + if (!targetNumDef.elements) targetNumDef.elements = []; + + const levelSet = levels ? new Set(levels.map((level) => String(level))) : null; + let changed = false; + + for (const sourceEl of sourceNumDef.elements) { + if (sourceEl.name !== 'w:lvlOverride') continue; + + const ilvl = sourceEl.attributes?.['w:ilvl']; + if (ilvl == null) continue; + if (levelSet && !levelSet.has(ilvl)) continue; + + const nextElements = []; + for (const child of sourceEl.elements ?? []) { + if (child.name === 'w:startOverride') { + nextElements.push(deepCloneElement(child)); + continue; + } + + if (child.name === 'w:lvl') { + const lvlRestartElements = + child.elements + ?.filter((lvlChild) => lvlChild.name === 'w:lvlRestart') + .map((lvlChild) => deepCloneElement(lvlChild)) ?? []; + if (lvlRestartElements.length > 0) { + nextElements.push({ + type: 'element', + name: 'w:lvl', + attributes: { ...(child.attributes || {}), 'w:ilvl': child.attributes?.['w:ilvl'] ?? ilvl }, + elements: lvlRestartElements, + }); + } + } + } + + if (nextElements.length === 0) continue; + + const targetIndex = targetNumDef.elements.findIndex( + (el) => el.name === 'w:lvlOverride' && el.attributes?.['w:ilvl'] === ilvl, + ); + const nextOverride = { + type: 'element', + name: 'w:lvlOverride', + attributes: { ...(sourceEl.attributes || {}), 'w:ilvl': ilvl }, + elements: nextElements, + }; + + if (targetIndex === -1) { + targetNumDef.elements.push(nextOverride); + } else { + targetNumDef.elements[targetIndex] = nextOverride; + } + changed = true; + } + + return changed; +} + +/** + * Clone an abstract definition and create a new w:num pointing to it. + * Returns the new abstractNumId and numId. + * + * @param {import('../Editor').Editor} editor + * @param {number} originalAbstractNumId + * @param {number} originalNumId + * @returns {{ newAbstractNumId: number, newNumId: number }} + */ +function cloneAbstractAndNum(editor, originalAbstractNumId, originalNumId) { + const numbering = editor.converter.numbering; + const { newAbstractNumId } = cloneAbstractDefinition(editor, originalAbstractNumId); + // Find next available numId const existingNumIds = Object.keys(numbering.definitions).map(Number); const newNumId = existingNumIds.length > 0 ? Math.max(...existingNumIds) + 1 : 1; @@ -1057,6 +1243,7 @@ export const LevelFormattingHelpers = { // Override clearing hasLevelOverride, clearLevelOverride, + materializeLevelFormattingOverride, // Template operations captureTemplate, @@ -1067,7 +1254,9 @@ export const LevelFormattingHelpers = { // Clone-on-write isAbstractShared, + cloneAbstractIntoNum, cloneAbstractAndNum, + copySequenceStateOverrides, // Preset catalog getPresetTemplate, diff --git a/packages/super-editor/src/core/helpers/list-level-formatting-helpers.test.js b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.test.js index 1e8f78df7f..1b99077609 100644 --- a/packages/super-editor/src/core/helpers/list-level-formatting-helpers.test.js +++ b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.test.js @@ -405,6 +405,170 @@ describe('clearLevelOverride', () => { }); }); +// ────────────────────────────────────────────────────────────────────────────── +// materializeLevelFormattingOverride +// ────────────────────────────────────────────────────────────────────────────── + +describe('materializeLevelFormattingOverride', () => { + it('moves lvlOverride formatting into the abstract while preserving startOverride', () => { + const editor = makeEditor(); + const numDef = editor.converter.numbering.definitions[10]; + numDef.elements.push({ + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '0' }, + elements: [ + { type: 'element', name: 'w:startOverride', attributes: { 'w:val': '7' } }, + makeLvlElement(0, { + lvlText: '(%1)', + alignment: 'center', + left: 1440, + hanging: 1080, + suff: 'tab', + }), + ], + }); + + const before = LevelFormattingHelpers.captureEffectiveStyle(editor, 1, 10, [0]); + + const changed = LevelFormattingHelpers.materializeLevelFormattingOverride(editor, 1, 10, 0); + + expect(changed).toBe(true); + expect(LevelFormattingHelpers.captureEffectiveStyle(editor, 1, 10, [0])).toEqual(before); + + const lvl0 = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl0.elements.find((e) => e.name === 'w:lvlText').attributes['w:val']).toBe('(%1)'); + expect(lvl0.elements.find((e) => e.name === 'w:lvlJc').attributes['w:val']).toBe('center'); + const ind = lvl0.elements.find((e) => e.name === 'w:pPr').elements.find((e) => e.name === 'w:ind'); + expect(ind.attributes['w:left']).toBe('1440'); + expect(ind.attributes['w:hanging']).toBe('1080'); + + const remainingOverride = numDef.elements.find((e) => e.name === 'w:lvlOverride'); + expect(remainingOverride).toBeDefined(); + expect(remainingOverride.elements).toEqual([ + { type: 'element', name: 'w:startOverride', attributes: { 'w:val': '7' } }, + ]); + }); + + it('returns false when only startOverride exists', () => { + const editor = makeEditor(); + const numDef = editor.converter.numbering.definitions[10]; + numDef.elements.push({ + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '0' }, + elements: [{ type: 'element', name: 'w:startOverride', attributes: { 'w:val': '7' } }], + }); + + const changed = LevelFormattingHelpers.materializeLevelFormattingOverride(editor, 1, 10, 0); + + expect(changed).toBe(false); + expect(numDef.elements).toHaveLength(2); + }); + + it('preserves instance lvlRestart when materializing formatting overrides', () => { + const editor = makeEditor(); + const numDef = editor.converter.numbering.definitions[10]; + numDef.elements.push({ + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '0' }, + elements: [ + makeLvlElement(0, { + lvlText: '(%1)', + }), + ], + }); + numDef.elements[1].elements[0].elements.push({ + type: 'element', + name: 'w:lvlRestart', + attributes: { 'w:val': '1' }, + }); + + const changed = LevelFormattingHelpers.materializeLevelFormattingOverride(editor, 1, 10, 0); + + expect(changed).toBe(true); + const remainingOverride = numDef.elements.find((e) => e.name === 'w:lvlOverride'); + expect(remainingOverride).toBeDefined(); + expect(remainingOverride.elements).toEqual([ + { + type: 'element', + name: 'w:lvl', + attributes: { 'w:ilvl': '0' }, + elements: [{ type: 'element', name: 'w:lvlRestart', attributes: { 'w:val': '1' } }], + }, + ]); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// cloneAbstractIntoNum +// ────────────────────────────────────────────────────────────────────────────── + +describe('cloneAbstractIntoNum', () => { + it('retargets the existing num to a cloned abstract and preserves startOverride', () => { + const editor = makeEditor(); + const numDef = editor.converter.numbering.definitions[10]; + numDef.elements.push({ + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '0' }, + elements: [{ type: 'element', name: 'w:startOverride', attributes: { 'w:val': '7' } }], + }); + + const { newAbstractNumId } = LevelFormattingHelpers.cloneAbstractIntoNum(editor, 1, 10); + + expect(newAbstractNumId).toBe(2); + expect( + Object.keys(editor.converter.numbering.abstracts) + .map(Number) + .sort((a, b) => a - b), + ).toEqual([1, 2]); + expect(Object.keys(editor.converter.numbering.definitions).map(Number)).toEqual([10]); + + const abstractNumIdEl = numDef.elements.find((e) => e.name === 'w:abstractNumId'); + expect(abstractNumIdEl.attributes['w:val']).toBe('2'); + + const override = numDef.elements.find((e) => e.name === 'w:lvlOverride'); + expect(override).toBeDefined(); + expect(override.elements).toEqual([{ type: 'element', name: 'w:startOverride', attributes: { 'w:val': '7' } }]); + }); +}); + +describe('copySequenceStateOverrides', () => { + it('copies startOverride to the cloned num without restoring formatting overrides', () => { + const editor = makeEditor(); + editor.converter.numbering.definitions[11] = { + type: 'element', + name: 'w:num', + attributes: { 'w:numId': '11' }, + elements: [{ type: 'element', name: 'w:abstractNumId', attributes: { 'w:val': '1' } }], + }; + + const sourceNum = editor.converter.numbering.definitions[10]; + sourceNum.elements.push({ + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '0' }, + elements: [ + { type: 'element', name: 'w:startOverride', attributes: { 'w:val': '7' } }, + makeLvlElement(0, { lvlText: '(%1)' }), + ], + }); + + const changed = LevelFormattingHelpers.copySequenceStateOverrides(editor, 10, 11, [0]); + + expect(changed).toBe(true); + const targetOverride = editor.converter.numbering.definitions[11].elements.find((e) => e.name === 'w:lvlOverride'); + expect(targetOverride).toEqual({ + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '0' }, + elements: [{ type: 'element', name: 'w:startOverride', attributes: { 'w:val': '7' } }], + }); + }); +}); + // ────────────────────────────────────────────────────────────────────────────── // captureTemplate // ────────────────────────────────────────────────────────────────────────────── diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.test.ts index d85baf6776..b8ef004cf3 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.test.ts @@ -41,6 +41,7 @@ vi.mock('../helpers/list-item-resolver.js', () => ({ vi.mock('../helpers/list-sequence-helpers.js', () => ({ getAbstractNumId: vi.fn(), + getAllListItemProjections: vi.fn(() => []), getContiguousSequence: vi.fn(() => []), findAdjacentSequence: vi.fn(() => null), })); @@ -63,17 +64,27 @@ vi.mock('../../core/helpers/list-level-formatting-helpers.js', () => ({ LevelFormattingHelpers: { getPresetTemplate: vi.fn(), applyTemplateToAbstract: vi.fn(), + captureEffectiveStyle: vi.fn(), hasLevel: vi.fn(() => true), hasLevelOverride: vi.fn(() => false), clearLevelOverride: vi.fn(), + materializeLevelFormattingOverride: vi.fn(() => false), + copySequenceStateOverrides: vi.fn(() => false), captureTemplate: vi.fn(), + isAbstractShared: vi.fn(() => false), + cloneAbstractIntoNum: vi.fn(() => ({ newAbstractNumId: 98 })), + cloneAbstractAndNum: vi.fn(() => ({ newAbstractNumId: 99, newNumId: 199 })), setLevelNumberingFormat: vi.fn(() => true), + setLevelNumberStyle: vi.fn(() => true), + setLevelText: vi.fn(() => true), + setLevelStart: vi.fn(() => true), setLevelBulletMarker: vi.fn(() => true), setLevelPictureBullet: vi.fn(() => true), setLevelAlignment: vi.fn(() => true), setLevelIndents: vi.fn(() => true), setLevelTrailingCharacter: vi.fn(() => true), setLevelMarkerFont: vi.fn(() => true), + setLevelLayout: vi.fn(() => ({ changed: true })), }, })); @@ -81,9 +92,14 @@ vi.mock('../../core/helpers/list-level-formatting-helpers.js', () => ({ // Now import wrappers and mocked modules // --------------------------------------------------------------------------- -import { listsSetTypeWrapper } from './lists-formatting-wrappers.js'; +import { listsApplyStyleWrapper, listsSetLevelTextWrapper, listsSetTypeWrapper } from './lists-formatting-wrappers.js'; import { resolveListItem } from '../helpers/list-item-resolver.js'; -import { getAbstractNumId, getContiguousSequence, findAdjacentSequence } from '../helpers/list-sequence-helpers.js'; +import { + getAbstractNumId, + getAllListItemProjections, + getContiguousSequence, + findAdjacentSequence, +} from '../helpers/list-sequence-helpers.js'; import { LevelFormattingHelpers } from '../../core/helpers/list-level-formatting-helpers.js'; import { updateNumberingProperties } from '../../core/commands/changeListLevel.js'; import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; @@ -163,19 +179,44 @@ const MOCK_TEMPLATE = { version: 1, levels: [{ level: 0, numFmt: 'decimal', lvlT * detect a real change via `syncNumberingToXmlTree`. */ function mockApplyTemplateChanged(editorRef: Editor): void { - vi.mocked(LevelFormattingHelpers.applyTemplateToAbstract).mockImplementation(() => { + vi.mocked(LevelFormattingHelpers.applyTemplateToAbstract).mockImplementation((_editor, abstractNumId) => { const conv = (editorRef as unknown as { converter: { numbering: { abstracts: Record } } }) .converter; - conv.numbering.abstracts[10] = { + conv.numbering.abstracts[abstractNumId] = { type: 'element', name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '10' }, + attributes: { 'w:abstractNumId': String(abstractNumId) }, elements: [{ type: 'element', name: 'w:lvl', attributes: { 'w:ilvl': '0' }, elements: [] }], }; return { changed: true }; }); } +function mockSetLevelTextChanged(editorRef: Editor): void { + vi.mocked(LevelFormattingHelpers.setLevelText).mockImplementation((_editor, abstractNumId, ilvl, text) => { + const conv = (editorRef as unknown as { converter: { numbering: { abstracts: Record } } }).converter; + if (!conv.numbering.abstracts[abstractNumId]) { + conv.numbering.abstracts[abstractNumId] = { + type: 'element', + name: 'w:abstractNum', + attributes: { 'w:abstractNumId': String(abstractNumId) }, + elements: [{ type: 'element', name: 'w:lvl', attributes: { 'w:ilvl': String(ilvl) }, elements: [] }], + }; + } + const lvl = conv.numbering.abstracts[abstractNumId].elements.find( + (el: any) => el.name === 'w:lvl' && el.attributes?.['w:ilvl'] === String(ilvl), + ); + const existing = lvl.elements.find((el: any) => el.name === 'w:lvlText'); + if (existing?.attributes?.['w:val'] === text) return false; + if (existing) { + existing.attributes['w:val'] = text; + } else { + lvl.elements.push({ type: 'element', name: 'w:lvlText', attributes: { 'w:val': text } }); + } + return true; + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -561,3 +602,92 @@ describe('listsSetTypeWrapper', () => { ).toThrow(); }); }); + +describe('SD-2025 style wrappers', () => { + it('materializes formatting overrides instead of clearing the whole lvlOverride during applyStyle', () => { + const target = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValue(target); + vi.mocked(getAbstractNumId).mockReturnValue(10); + mockApplyTemplateChanged(editor); + + const result = listsApplyStyleWrapper(editor, { + target: target.address, + style: { version: 1, levels: [{ level: 0, lvlText: '(%1)' }] }, + }); + + expect(result.success).toBe(true); + expect(LevelFormattingHelpers.materializeLevelFormattingOverride).toHaveBeenCalledWith(editor, 10, 1, 0); + expect(LevelFormattingHelpers.clearLevelOverride).not.toHaveBeenCalled(); + }); + + it('retargets the existing num when the target sequence already owns its numId', () => { + const target = makeProjection({ numId: 10, level: 0 }); + vi.mocked(resolveListItem).mockReturnValue(target); + vi.mocked(getAbstractNumId).mockReturnValue(10); + vi.mocked(getContiguousSequence).mockReturnValue([target]); + vi.mocked(getAllListItemProjections).mockReturnValue([target]); + vi.mocked(LevelFormattingHelpers.isAbstractShared).mockReturnValue(true); + mockApplyTemplateChanged(editor); + + const result = listsApplyStyleWrapper(editor, { + target: target.address, + style: { version: 1, levels: [{ level: 0, lvlText: '(%1)' }] }, + }); + + expect(result.success).toBe(true); + expect(LevelFormattingHelpers.cloneAbstractIntoNum).toHaveBeenCalledWith(editor, 10, 10); + expect(LevelFormattingHelpers.cloneAbstractAndNum).not.toHaveBeenCalled(); + expect(updateNumberingProperties).not.toHaveBeenCalled(); + }); + + it('preserves sequence-state overrides when clone-on-write allocates a fresh num', () => { + const target = makeProjection({ numId: 10, level: 0 }); + const other = makeProjection({ + numId: 10, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'p2' }, + candidate: { + nodeType: 'listItem', + nodeId: 'p2', + node: { attrs: { paragraphProperties: { numberingProperties: { numId: 10, ilvl: 0 } } } }, + pos: 30, + end: 40, + }, + }); + + vi.mocked(resolveListItem).mockReturnValue(target); + vi.mocked(getAbstractNumId).mockReturnValue(10); + vi.mocked(getContiguousSequence).mockReturnValue([target]); + vi.mocked(getAllListItemProjections).mockReturnValue([target, other]); + vi.mocked(LevelFormattingHelpers.isAbstractShared).mockReturnValue(true); + mockApplyTemplateChanged(editor); + + const result = listsApplyStyleWrapper(editor, { + target: target.address, + style: { version: 1, levels: [{ level: 0, lvlText: '(%1)' }] }, + }); + + expect(result.success).toBe(true); + expect(LevelFormattingHelpers.cloneAbstractAndNum).toHaveBeenCalledWith(editor, 10, 10); + expect(LevelFormattingHelpers.copySequenceStateOverrides).toHaveBeenCalledWith(editor, 10, 199, [0]); + }); + + it('materializes formatting overrides before sequence-local level edits', () => { + const target = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValue(target); + vi.mocked(getAbstractNumId).mockReturnValue(10); + mockSetLevelTextChanged(editor); + + const result = listsSetLevelTextWrapper(editor, { + target: target.address, + level: 0, + text: '(%1)', + }); + + expect(result.success).toBe(true); + expect(LevelFormattingHelpers.materializeLevelFormattingOverride).toHaveBeenCalledWith(editor, 10, 1, 0); + expect(LevelFormattingHelpers.setLevelText).toHaveBeenCalledWith(editor, 10, 0, '(%1)'); + expect( + vi.mocked(LevelFormattingHelpers.materializeLevelFormattingOverride).mock.invocationCallOrder[0], + ).toBeLessThan(vi.mocked(LevelFormattingHelpers.setLevelText).mock.invocationCallOrder[0]); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts index 8954a08592..9578a9491f 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts @@ -44,7 +44,12 @@ import { compoundMutation } from '../../core/parts/mutation/compound-mutation.js import { syncNumberingToXmlTree } from '../../core/parts/adapters/numbering-part-descriptor.js'; import type { PartId } from '../../core/parts/types.js'; import { resolveListItem, type ListItemProjection } from '../helpers/list-item-resolver.js'; -import { getAbstractNumId, getContiguousSequence, findAdjacentSequence } from '../helpers/list-sequence-helpers.js'; +import { + getAbstractNumId, + getAllListItemProjections, + getContiguousSequence, + findAdjacentSequence, +} from '../helpers/list-sequence-helpers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; import { LevelFormattingHelpers } from '../../core/helpers/list-level-formatting-helpers.js'; import { updateNumberingProperties } from '../../core/commands/changeListLevel.js'; @@ -825,14 +830,15 @@ export function listsSetLevelMarkerFontWrapper( type SequenceLocalResult = { abstractNumId: number; numId: number; + sourceNumId: number; pendingRebind: ListItemProjection[] | null; }; /** * Ensure the target sequence has its own private abstract definition. - * If the abstract is shared, clones the abstract and creates a new w:num - * in converter data ONLY (no PM dispatch). Returns the rebinding info - * so the caller can apply it in its own transaction. + * If the abstract is shared, prefer retargeting the existing w:num when + * the sequence already owns its numId. Only allocate a fresh w:num when + * that numId is reused outside the target sequence. */ function ensureSequenceLocalAbstract( editor: Editor, @@ -841,7 +847,19 @@ function ensureSequenceLocalAbstract( targetNumId: number, ): SequenceLocalResult { if (!LevelFormattingHelpers.isAbstractShared(editor, targetAbstractNumId, targetNumId)) { - return { abstractNumId: targetAbstractNumId, numId: targetNumId, pendingRebind: null }; + return { abstractNumId: targetAbstractNumId, numId: targetNumId, sourceNumId: targetNumId, pendingRebind: null }; + } + + const sequence = getContiguousSequence(editor, target); + const sequenceNodeIds = new Set(sequence.map((item) => item.address.nodeId)); + const allItemsWithNumId = getAllListItemProjections(editor).filter((item) => item.numId === targetNumId); + + if ( + allItemsWithNumId.length === sequence.length && + allItemsWithNumId.every((item) => sequenceNodeIds.has(item.address.nodeId)) + ) { + const { newAbstractNumId } = LevelFormattingHelpers.cloneAbstractIntoNum(editor, targetAbstractNumId, targetNumId); + return { abstractNumId: newAbstractNumId, numId: targetNumId, sourceNumId: targetNumId, pendingRebind: null }; } const { newAbstractNumId, newNumId } = LevelFormattingHelpers.cloneAbstractAndNum( @@ -850,8 +868,7 @@ function ensureSequenceLocalAbstract( targetNumId, ); - const sequence = getContiguousSequence(editor, target); - return { abstractNumId: newAbstractNumId, numId: newNumId, pendingRebind: sequence }; + return { abstractNumId: newAbstractNumId, numId: newNumId, sourceNumId: targetNumId, pendingRebind: sequence }; } /** @@ -922,8 +939,10 @@ function executeSequenceLocalLevelMutation( source: operationId, expectedRevision: options?.expectedRevision, mutate({ part }) { + LevelFormattingHelpers.materializeLevelFormattingOverride(editor, local.abstractNumId, local.numId, level); const changed = mutate(local.abstractNumId, level); if (!changed) return false; + LevelFormattingHelpers.copySequenceStateOverrides(editor, local.sourceNumId, local.numId, [level]); syncNumberingToXmlTree(part, getConverterNumbering(editor)); return true; }, @@ -1025,6 +1044,11 @@ export function listsApplyStyleWrapper( source: 'lists.applyStyle', expectedRevision: options?.expectedRevision, mutate({ part }) { + const affectedLevels = normalized ?? input.style.levels.map((l) => l.level); + for (const ilvl of affectedLevels) { + LevelFormattingHelpers.materializeLevelFormattingOverride(editor, local.abstractNumId, local.numId, ilvl); + } + const applyResult = LevelFormattingHelpers.applyTemplateToAbstract( editor, local.abstractNumId, @@ -1036,15 +1060,8 @@ export function listsApplyStyleWrapper( return false; } - // Clear lvlOverride formatting on affected levels so overrides - // don't shadow the newly applied abstract values. - let overridesCleared = false; - const affectedLevels = normalized ?? input.style.levels.map((l) => l.level); - for (const ilvl of affectedLevels) { - overridesCleared = LevelFormattingHelpers.clearLevelOverride(editor, local.numId, ilvl) || overridesCleared; - } - - if (!applyResult.changed && !overridesCleared) return false; + if (!applyResult.changed) return false; + LevelFormattingHelpers.copySequenceStateOverrides(editor, local.sourceNumId, local.numId, affectedLevels); syncNumberingToXmlTree(part, getConverterNumbering(editor)); return true; }, @@ -1225,6 +1242,12 @@ export function listsSetLevelLayoutWrapper( source: 'lists.setLevelLayout', expectedRevision: options?.expectedRevision, mutate({ part }) { + LevelFormattingHelpers.materializeLevelFormattingOverride( + editor, + local.abstractNumId, + local.numId, + input.level, + ); const layoutResult = LevelFormattingHelpers.setLevelLayout( editor, local.abstractNumId, diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.test.ts index 807ee2db61..a2c51f978b 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.test.ts @@ -46,6 +46,7 @@ vi.mock('../helpers/list-sequence-helpers.js', () => ({ resolveBlock: vi.fn(), resolveBlocksInRange: vi.fn(), getAbstractNumId: vi.fn(), + getAllListItemProjections: vi.fn(), getContiguousSequence: vi.fn(), getSequenceFromTarget: vi.fn(), isFirstInSequence: vi.fn(), @@ -114,6 +115,7 @@ import { resolveBlock, resolveBlocksInRange, getAbstractNumId, + getAllListItemProjections, getContiguousSequence, getSequenceFromTarget, isFirstInSequence, @@ -124,6 +126,7 @@ import { findPreviousCompatibleSequence, } from '../helpers/list-sequence-helpers.js'; import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; +import { updateNumberingProperties } from '../../core/commands/changeListLevel.js'; import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; // --------------------------------------------------------------------------- @@ -325,6 +328,40 @@ describe('lists-wrappers', () => { expect(result.success).toBe(false); expect((result as any).failure.code).toBe('LEVEL_OUT_OF_RANGE'); }); + + it('allows continuePrevious without an explicit kind', () => { + const block = makeBlockCandidate('p3', 'paragraph'); + const previous = makeProjection({ + numId: 7, + kind: 'ordered', + candidate: { + nodeType: 'listItem', + nodeId: 'prev-item', + node: { attrs: { paragraphProperties: { numberingProperties: { numId: 7, ilvl: 0 } } } }, + pos: 5, + end: 9, + }, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'prev-item' }, + }); + + vi.mocked(resolveBlock).mockReturnValueOnce(block as any); + vi.mocked(getAllListItemProjections).mockReturnValueOnce([previous] as any); + + const result = listsCreateWrapper(editor, { + mode: 'empty', + at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p3' }, + sequence: { mode: 'continuePrevious' }, + }); + + expect(result.success).toBe(true); + expect(updateNumberingProperties).toHaveBeenCalledWith( + { numId: 7, ilvl: 0 }, + block.node, + block.pos, + editor, + expect.anything(), + ); + }); }); // ========================================================================= diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts index 33b664fd99..79b5923e68 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts @@ -478,6 +478,10 @@ function resolveCreateKind(input: ListsCreateInput): { kind: 'ordered' | 'bullet return { kind: raw.kind as 'ordered' | 'bullet' }; } +function isListKind(value: unknown): value is 'ordered' | 'bullet' { + return value === 'ordered' || value === 'bullet'; +} + export function listsCreateWrapper( editor: Editor, input: ListsCreateInput, @@ -502,13 +506,6 @@ export function listsCreateWrapper( return toListsFailure('LEVEL_OUT_OF_RANGE', 'Level must be between 0 and 8.', { level }); } - // Resolve kind (may fail with INVALID_INPUT) - const kindResult = resolveCreateKind(input); - if ('failure' in kindResult) return kindResult.failure; - const { kind } = kindResult; - - const listType = kind === 'ordered' ? 'orderedList' : 'bulletList'; - // Resolve style template to apply (if any) let styleTemplate: ListTemplate | undefined; if (raw.style != null) { @@ -554,6 +551,22 @@ export function listsCreateWrapper( // Sequence mode resolution const sequenceInput = (raw.sequence as { mode: string; startAt?: number } | undefined) ?? { mode: 'new' }; + const requestedKind = raw.kind; + if (requestedKind != null && !isListKind(requestedKind)) { + return toListsFailure('INVALID_INPUT', `Unknown list kind: ${String(requestedKind)}.`, { kind: requestedKind }); + } + + let kind: 'ordered' | 'bullet' | undefined; + let listType: 'orderedList' | 'bulletList' | undefined; + + if (sequenceInput.mode !== 'continuePrevious') { + const kindResult = resolveCreateKind(input); + if ('failure' in kindResult) return kindResult.failure; + kind = kindResult.kind; + listType = kind === 'ordered' ? 'orderedList' : 'bulletList'; + } else { + kind = requestedKind as 'ordered' | 'bullet' | undefined; + } // Pre-flight continuePrevious compatibility BEFORE any mutations. // continuePrevious binds the new paragraphs to an existing sequence's @@ -576,10 +589,9 @@ export function listsCreateWrapper( const item = allItems[i]!; if (item.candidate.pos >= firstBlockPos) continue; if (item.numId == null) continue; - if (item.kind === kind) { - continuePreviousNumId = item.numId; - break; - } + if (kind != null && item.kind !== kind) continue; + continuePreviousNumId = item.numId; + break; } if (continuePreviousNumId == null) { return toListsFailure('NO_COMPATIBLE_PREVIOUS', 'No compatible previous list sequence found.', {}); @@ -607,7 +619,7 @@ export function listsCreateWrapper( } else { // mode: 'new' — allocate a fresh definition numId = ListHelpers.getNewListId(editor); - ListHelpers.generateNewListDefinition({ numId, listType, editor }); + ListHelpers.generateNewListDefinition({ numId, listType: listType!, editor }); // Apply style/preset template if provided if (styleTemplate) { diff --git a/tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts b/tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts index 1d2e0318db..86cb396ef1 100644 --- a/tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts +++ b/tests/doc-api-stories/tests/lists/style-commands-roundtrip.ts @@ -46,6 +46,7 @@ function requireLevel(style: ListStyle, level: number) { describe('document-api story: lists style commands roundtrip', () => { const { client, copyDoc, outPath, runCli } = useStoryHarness('lists/style-commands-roundtrip', { preserveResults: true, + cliBinMode: 'source', }); const api = client as any; @@ -302,4 +303,43 @@ describe('document-api story: lists style commands roundtrip', () => { expect(numberingXml).toMatch(/w:lvlJc[^>]*w:val="center"/); expect(numberingXml).toMatch(/w:suff[^>]*w:val="tab"/); }); + + it('preserves restart overrides when applying a style to the same sequence', async () => { + const { sessionId, resultDoc } = await openFixture('style-preserves-restart'); + const fixture = await resolvePreSeparatedFixture(sessionId); + + const presetResult = await callDocOperation('lists.applyPreset', { + sessionId, + target: fixture.secondItem, + preset: 'upperRoman', + }); + assertMutationSuccess('lists.applyPreset', presetResult); + + const restartResult = await callDocOperation('lists.restartAt', { + sessionId, + target: fixture.secondItem, + startAt: 7, + }); + assertMutationSuccess('lists.restartAt', restartResult); + + const styleBefore = await getStyle(sessionId, fixture.secondItem); + const editedStyle = cloneStyle(styleBefore); + requireLevel(editedStyle, 0).lvlText = '(%1)'; + + const applyResult = await callDocOperation('lists.applyStyle', { + sessionId, + target: fixture.secondItem, + style: editedStyle, + }); + assertMutationSuccess('lists.applyStyle', applyResult); + + const styleAfter = await getStyle(sessionId, fixture.secondItem); + expect(requireLevel(styleAfter, 0).lvlText).toBe('(%1)'); + + await saveResult(sessionId, resultDoc); + + const numberingXml = await requireZipEntry(resultDoc, NUMBERING_PART); + expect(numberingXml).toContain('(%1)'); + expect(numberingXml).toMatch(/w:startOverride[^>]*w:val="7"/); + }); });