diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 46333d3d2a..3511eb62d1 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -127,6 +127,17 @@ const INTENT_NAMES = { 'doc.lists.canContinuePrevious': 'can_continue_previous_list', 'doc.lists.setLevelRestart': 'set_list_level_restart', 'doc.lists.convertToText': 'convert_list_to_text', + 'doc.lists.applyTemplate': 'apply_list_template', + 'doc.lists.applyPreset': 'apply_list_preset', + 'doc.lists.captureTemplate': 'capture_list_template', + 'doc.lists.setLevelNumbering': 'set_list_level_numbering', + 'doc.lists.setLevelBullet': 'set_list_level_bullet', + 'doc.lists.setLevelPictureBullet': 'set_list_level_picture_bullet', + 'doc.lists.setLevelAlignment': 'set_list_level_alignment', + 'doc.lists.setLevelIndents': 'set_list_level_indents', + 'doc.lists.setLevelTrailingCharacter': 'set_list_level_trailing_character', + 'doc.lists.setLevelMarkerFont': 'set_list_level_marker_font', + 'doc.lists.clearLevelOverrides': 'clear_list_level_overrides', 'doc.comments.create': 'create_comment', 'doc.comments.patch': 'patch_comment', 'doc.comments.delete': 'delete_comment', diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 884ebfe2bb..81ddbe8a11 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -1516,6 +1516,189 @@ export const SUCCESS_SCENARIOS = { ], }; }, + 'doc.lists.applyTemplate': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-apply-template-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-apply-template'); + const target = await harness.firstListItemAddress(docPath, stateDir); + const template = { + version: 1, + levels: [{ level: 0, numFmt: 'decimal', lvlText: '%1.' }], + }; + return { + stateDir, + args: [ + 'lists', + 'apply-template', + docPath, + '--input-json', + JSON.stringify({ target, template }), + '--out', + harness.createOutputPath('doc-lists-apply-template-output'), + ], + }; + }, + 'doc.lists.applyPreset': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-apply-preset-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-apply-preset'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'apply-preset', + docPath, + '--input-json', + JSON.stringify({ target, preset: 'decimal' }), + '--out', + harness.createOutputPath('doc-lists-apply-preset-output'), + ], + }; + }, + 'doc.lists.captureTemplate': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-capture-template-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-capture-template'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: ['lists', 'capture-template', docPath, '--input-json', JSON.stringify({ target })], + }; + }, + 'doc.lists.setLevelNumbering': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-numbering-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-numbering'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-numbering', + docPath, + '--input-json', + JSON.stringify({ target, level: 0, numFmt: 'decimal', lvlText: '%1.' }), + '--out', + harness.createOutputPath('doc-lists-set-level-numbering-output'), + ], + }; + }, + 'doc.lists.setLevelBullet': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-bullet-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-bullet'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-bullet', + docPath, + '--input-json', + JSON.stringify({ target, level: 0, markerText: '\u2022' }), + '--out', + harness.createOutputPath('doc-lists-set-level-bullet-output'), + ], + }; + }, + 'doc.lists.setLevelPictureBullet': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-picture-bullet-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-picture-bullet'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-picture-bullet', + docPath, + '--input-json', + JSON.stringify({ target, level: 0, pictureBulletId: 0 }), + '--out', + harness.createOutputPath('doc-lists-set-level-picture-bullet-output'), + ], + }; + }, + 'doc.lists.setLevelAlignment': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-alignment-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-alignment'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-alignment', + docPath, + '--input-json', + JSON.stringify({ target, level: 0, alignment: 'center' }), + '--out', + harness.createOutputPath('doc-lists-set-level-alignment-output'), + ], + }; + }, + 'doc.lists.setLevelIndents': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-indents-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-indents'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-indents', + docPath, + '--input-json', + JSON.stringify({ target, level: 0, left: 1440, hanging: 720 }), + '--out', + harness.createOutputPath('doc-lists-set-level-indents-output'), + ], + }; + }, + 'doc.lists.setLevelTrailingCharacter': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-trailing-character-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-trailing-character'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-trailing-character', + docPath, + '--input-json', + JSON.stringify({ target, level: 0, trailingCharacter: 'tab' }), + '--out', + harness.createOutputPath('doc-lists-set-level-trailing-character-output'), + ], + }; + }, + 'doc.lists.setLevelMarkerFont': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-marker-font-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-marker-font'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-marker-font', + docPath, + '--input-json', + JSON.stringify({ target, level: 0, fontFamily: 'Arial' }), + '--out', + harness.createOutputPath('doc-lists-set-level-marker-font-output'), + ], + }; + }, + 'doc.lists.clearLevelOverrides': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-clear-level-overrides-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-clear-level-overrides'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'clear-level-overrides', + docPath, + '--input-json', + JSON.stringify({ target, level: 0 }), + '--out', + harness.createOutputPath('doc-lists-clear-level-overrides-output'), + ], + }; + }, 'doc.insert': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-insert-success'); const docPath = await harness.copyFixtureDoc('doc-insert'); @@ -2327,6 +2510,9 @@ const RUNTIME_CONFORMANCE_SKIP = new Set([ // which the CLI test harness fixture does not populate. 'doc.tables.setDefaultStyle', 'doc.tables.clearDefaultStyle', + // clearLevelOverrides requires an instance-level override to exist on the fixture list, + // which the generic list fixture does not have. + 'doc.lists.clearLevelOverrides', ]); export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => { diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 74bd34b381..3e0fce6c9b 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -105,6 +105,17 @@ export const SUCCESS_VERB: Record = { 'lists.continuePrevious': 'continued previous list', 'lists.canContinuePrevious': 'checked continue feasibility', 'lists.setLevelRestart': 'set level restart', + 'lists.applyTemplate': 'applied list template', + 'lists.applyPreset': 'applied list preset', + 'lists.captureTemplate': 'captured list template', + 'lists.setLevelNumbering': 'set level numbering', + 'lists.setLevelBullet': 'set level bullet', + 'lists.setLevelPictureBullet': 'set level picture bullet', + 'lists.setLevelAlignment': 'set level alignment', + 'lists.setLevelIndents': 'set level indents', + 'lists.setLevelTrailingCharacter': 'set level trailing character', + 'lists.setLevelMarkerFont': 'set level marker font', + 'lists.clearLevelOverrides': 'cleared level overrides', 'lists.convertToText': 'converted list to text', 'comments.create': 'created comment', 'comments.patch': 'patched comment', @@ -256,6 +267,17 @@ export const OUTPUT_FORMAT: Record = { 'lists.continuePrevious': 'listsMutationResult', 'lists.canContinuePrevious': 'plain', 'lists.setLevelRestart': 'listsMutationResult', + 'lists.applyTemplate': 'listsMutationResult', + 'lists.applyPreset': 'listsMutationResult', + 'lists.captureTemplate': 'plain', + 'lists.setLevelNumbering': 'listsMutationResult', + 'lists.setLevelBullet': 'listsMutationResult', + 'lists.setLevelPictureBullet': 'listsMutationResult', + 'lists.setLevelAlignment': 'listsMutationResult', + 'lists.setLevelIndents': 'listsMutationResult', + 'lists.setLevelTrailingCharacter': 'listsMutationResult', + 'lists.setLevelMarkerFont': 'listsMutationResult', + 'lists.clearLevelOverrides': 'listsMutationResult', 'lists.convertToText': 'listsMutationResult', 'comments.create': 'commentReceipt', 'comments.patch': 'commentReceipt', @@ -391,6 +413,17 @@ export const RESPONSE_ENVELOPE_KEY: Record 'lists.continuePrevious': 'result', 'lists.canContinuePrevious': 'result', 'lists.setLevelRestart': 'result', + 'lists.applyTemplate': 'result', + 'lists.applyPreset': 'result', + 'lists.captureTemplate': 'result', + 'lists.setLevelNumbering': 'result', + 'lists.setLevelBullet': 'result', + 'lists.setLevelPictureBullet': 'result', + 'lists.setLevelAlignment': 'result', + 'lists.setLevelIndents': 'result', + 'lists.setLevelTrailingCharacter': 'result', + 'lists.setLevelMarkerFont': 'result', + 'lists.clearLevelOverrides': 'result', 'lists.convertToText': 'result', 'comments.create': 'receipt', 'comments.patch': 'receipt', @@ -555,6 +588,17 @@ export const OPERATION_FAMILY: Record = 'lists.continuePrevious': 'lists', 'lists.canContinuePrevious': 'lists', 'lists.setLevelRestart': 'lists', + 'lists.applyTemplate': 'lists', + 'lists.applyPreset': 'lists', + 'lists.captureTemplate': 'lists', + 'lists.setLevelNumbering': 'lists', + 'lists.setLevelBullet': 'lists', + 'lists.setLevelPictureBullet': 'lists', + 'lists.setLevelAlignment': 'lists', + 'lists.setLevelIndents': 'lists', + 'lists.setLevelTrailingCharacter': 'lists', + 'lists.setLevelMarkerFont': 'lists', + 'lists.clearLevelOverrides': 'lists', 'lists.convertToText': 'lists', 'comments.create': 'comments', 'comments.patch': 'comments', diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index 023f3797dc..2d33a1c3ba 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -410,6 +410,17 @@ const EXTRA_CLI_PARAMS: Partial> = { ...LIST_TARGET_FLAT_PARAMS, ], 'doc.lists.setLevelRestart': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.applyTemplate': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.applyPreset': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.captureTemplate': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.setLevelNumbering': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.setLevelBullet': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.setLevelPictureBullet': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.setLevelAlignment': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.setLevelIndents': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.setLevelTrailingCharacter': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.setLevelMarkerFont': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.clearLevelOverrides': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], 'doc.lists.convertToText': [ { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS, diff --git a/apps/cli/src/lib/invoke-input.ts b/apps/cli/src/lib/invoke-input.ts index c919a5ad18..4b265bdb54 100644 --- a/apps/cli/src/lib/invoke-input.ts +++ b/apps/cli/src/lib/invoke-input.ts @@ -39,6 +39,17 @@ const WRAPPED_INPUT_KEY: Partial> = { 'lists.continuePrevious': 'input', 'lists.canContinuePrevious': 'input', 'lists.setLevelRestart': 'input', + 'lists.applyTemplate': 'input', + 'lists.applyPreset': 'input', + 'lists.captureTemplate': 'input', + 'lists.setLevelNumbering': 'input', + 'lists.setLevelBullet': 'input', + 'lists.setLevelPictureBullet': 'input', + 'lists.setLevelAlignment': 'input', + 'lists.setLevelIndents': 'input', + 'lists.setLevelTrailingCharacter': 'input', + 'lists.setLevelMarkerFont': 'input', + 'lists.clearLevelOverrides': 'input', 'lists.convertToText': 'input', 'create.paragraph': 'input', 'create.heading': 'input', diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index e8a22a7335..8efbaf38a1 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -22,7 +22,7 @@ Use the tables below to see what operations are available and where each one is | Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | | Images | 13 | 0 | 13 | [Reference](/document-api/reference/images/index) | -| Lists | 17 | 0 | 17 | [Reference](/document-api/reference/lists/index) | +| Lists | 28 | 1 | 29 | [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) | @@ -136,6 +136,18 @@ Use the tables below to see what operations are available and where each one is | editor.doc.lists.canContinuePrevious(...) | [`lists.canContinuePrevious`](/document-api/reference/lists/can-continue-previous) | | editor.doc.lists.setLevelRestart(...) | [`lists.setLevelRestart`](/document-api/reference/lists/set-level-restart) | | editor.doc.lists.convertToText(...) | [`lists.convertToText`](/document-api/reference/lists/convert-to-text) | +| editor.doc.lists.applyTemplate(...) | [`lists.applyTemplate`](/document-api/reference/lists/apply-template) | +| editor.doc.lists.applyPreset(...) | [`lists.applyPreset`](/document-api/reference/lists/apply-preset) | +| editor.doc.lists.captureTemplate(...) | [`lists.captureTemplate`](/document-api/reference/lists/capture-template) | +| editor.doc.lists.setLevelNumbering(...) | [`lists.setLevelNumbering`](/document-api/reference/lists/set-level-numbering) | +| editor.doc.lists.setLevelBullet(...) | [`lists.setLevelBullet`](/document-api/reference/lists/set-level-bullet) | +| editor.doc.lists.setLevelPictureBullet(...) | [`lists.setLevelPictureBullet`](/document-api/reference/lists/set-level-picture-bullet) | +| editor.doc.lists.setLevelAlignment(...) | [`lists.setLevelAlignment`](/document-api/reference/lists/set-level-alignment) | +| editor.doc.lists.setLevelIndents(...) | [`lists.setLevelIndents`](/document-api/reference/lists/set-level-indents) | +| 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.setType(...) | [`lists.applyPreset`](/document-api/reference/lists/apply-preset) | | 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 419419296d..77d4bd631e 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -110,9 +110,13 @@ "apps/docs/document-api/reference/index.mdx", "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-template.mdx", "apps/docs/document-api/reference/lists/attach.mdx", "apps/docs/document-api/reference/lists/can-continue-previous.mdx", "apps/docs/document-api/reference/lists/can-join.mdx", + "apps/docs/document-api/reference/lists/capture-template.mdx", + "apps/docs/document-api/reference/lists/clear-level-overrides.mdx", "apps/docs/document-api/reference/lists/continue-previous.mdx", "apps/docs/document-api/reference/lists/convert-to-text.mdx", "apps/docs/document-api/reference/lists/create.mdx", @@ -125,7 +129,14 @@ "apps/docs/document-api/reference/lists/list.mdx", "apps/docs/document-api/reference/lists/outdent.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-marker-font.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-trailing-character.mdx", "apps/docs/document-api/reference/lists/set-level.mdx", "apps/docs/document-api/reference/lists/set-value.mdx", "apps/docs/document-api/reference/mutations/apply.mdx", @@ -351,7 +362,7 @@ "title": "Styles" }, { - "aliasMemberPaths": [], + "aliasMemberPaths": ["lists.setType"], "key": "lists", "operationIds": [ "lists.list", @@ -370,7 +381,18 @@ "lists.continuePrevious", "lists.canContinuePrevious", "lists.setLevelRestart", - "lists.convertToText" + "lists.convertToText", + "lists.applyTemplate", + "lists.applyPreset", + "lists.captureTemplate", + "lists.setLevelNumbering", + "lists.setLevelBullet", + "lists.setLevelPictureBullet", + "lists.setLevelAlignment", + "lists.setLevelIndents", + "lists.setLevelTrailingCharacter", + "lists.setLevelMarkerFont", + "lists.clearLevelOverrides" ], "pagePath": "apps/docs/document-api/reference/lists/index.mdx", "title": "Lists" @@ -533,5 +555,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "4a1730dcf3d8aecc2706b7507381afedffd8fd4bbf5e930d9afe3c6f79403c3a" + "sourceHash": "de6b000cae7d5e94443948d7401a56c0c835d2718f70f2298502956d379a57b1" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 9693e95301..a7bc604e75 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -767,6 +767,16 @@ _No fields._ | `operations.insert.dryRun` | boolean | yes | | | `operations.insert.reasons` | enum[] | no | | | `operations.insert.tracked` | boolean | yes | | +| `operations.lists.applyPreset` | object | yes | | +| `operations.lists.applyPreset.available` | boolean | yes | | +| `operations.lists.applyPreset.dryRun` | boolean | yes | | +| `operations.lists.applyPreset.reasons` | enum[] | no | | +| `operations.lists.applyPreset.tracked` | boolean | yes | | +| `operations.lists.applyTemplate` | object | yes | | +| `operations.lists.applyTemplate.available` | boolean | yes | | +| `operations.lists.applyTemplate.dryRun` | boolean | yes | | +| `operations.lists.applyTemplate.reasons` | enum[] | no | | +| `operations.lists.applyTemplate.tracked` | boolean | yes | | | `operations.lists.attach` | object | yes | | | `operations.lists.attach.available` | boolean | yes | | | `operations.lists.attach.dryRun` | boolean | yes | | @@ -782,6 +792,16 @@ _No fields._ | `operations.lists.canJoin.dryRun` | boolean | yes | | | `operations.lists.canJoin.reasons` | enum[] | no | | | `operations.lists.canJoin.tracked` | boolean | yes | | +| `operations.lists.captureTemplate` | object | yes | | +| `operations.lists.captureTemplate.available` | boolean | yes | | +| `operations.lists.captureTemplate.dryRun` | boolean | yes | | +| `operations.lists.captureTemplate.reasons` | enum[] | no | | +| `operations.lists.captureTemplate.tracked` | boolean | yes | | +| `operations.lists.clearLevelOverrides` | object | yes | | +| `operations.lists.clearLevelOverrides.available` | boolean | yes | | +| `operations.lists.clearLevelOverrides.dryRun` | boolean | yes | | +| `operations.lists.clearLevelOverrides.reasons` | enum[] | no | | +| `operations.lists.clearLevelOverrides.tracked` | boolean | yes | | | `operations.lists.continuePrevious` | object | yes | | | `operations.lists.continuePrevious.available` | boolean | yes | | | `operations.lists.continuePrevious.dryRun` | boolean | yes | | @@ -842,11 +862,46 @@ _No fields._ | `operations.lists.setLevel.dryRun` | boolean | yes | | | `operations.lists.setLevel.reasons` | enum[] | no | | | `operations.lists.setLevel.tracked` | boolean | yes | | +| `operations.lists.setLevelAlignment` | object | yes | | +| `operations.lists.setLevelAlignment.available` | boolean | yes | | +| `operations.lists.setLevelAlignment.dryRun` | boolean | yes | | +| `operations.lists.setLevelAlignment.reasons` | enum[] | no | | +| `operations.lists.setLevelAlignment.tracked` | boolean | yes | | +| `operations.lists.setLevelBullet` | object | yes | | +| `operations.lists.setLevelBullet.available` | boolean | yes | | +| `operations.lists.setLevelBullet.dryRun` | boolean | yes | | +| `operations.lists.setLevelBullet.reasons` | enum[] | no | | +| `operations.lists.setLevelBullet.tracked` | boolean | yes | | +| `operations.lists.setLevelIndents` | object | yes | | +| `operations.lists.setLevelIndents.available` | boolean | yes | | +| `operations.lists.setLevelIndents.dryRun` | boolean | yes | | +| `operations.lists.setLevelIndents.reasons` | enum[] | no | | +| `operations.lists.setLevelIndents.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.setLevelNumbering` | object | yes | | +| `operations.lists.setLevelNumbering.available` | boolean | yes | | +| `operations.lists.setLevelNumbering.dryRun` | boolean | yes | | +| `operations.lists.setLevelNumbering.reasons` | enum[] | no | | +| `operations.lists.setLevelNumbering.tracked` | boolean | yes | | +| `operations.lists.setLevelPictureBullet` | object | yes | | +| `operations.lists.setLevelPictureBullet.available` | boolean | yes | | +| `operations.lists.setLevelPictureBullet.dryRun` | boolean | yes | | +| `operations.lists.setLevelPictureBullet.reasons` | enum[] | no | | +| `operations.lists.setLevelPictureBullet.tracked` | boolean | yes | | | `operations.lists.setLevelRestart` | object | yes | | | `operations.lists.setLevelRestart.available` | boolean | yes | | | `operations.lists.setLevelRestart.dryRun` | boolean | yes | | | `operations.lists.setLevelRestart.reasons` | enum[] | no | | | `operations.lists.setLevelRestart.tracked` | boolean | yes | | +| `operations.lists.setLevelTrailingCharacter` | object | yes | | +| `operations.lists.setLevelTrailingCharacter.available` | boolean | yes | | +| `operations.lists.setLevelTrailingCharacter.dryRun` | boolean | yes | | +| `operations.lists.setLevelTrailingCharacter.reasons` | enum[] | no | | +| `operations.lists.setLevelTrailingCharacter.tracked` | boolean | yes | | | `operations.lists.setValue` | object | yes | | | `operations.lists.setValue.available` | boolean | yes | | | `operations.lists.setValue.dryRun` | boolean | yes | | @@ -2350,6 +2405,22 @@ _No fields._ ], "tracked": true }, + "lists.applyPreset": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.applyTemplate": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "lists.attach": { "available": true, "dryRun": true, @@ -2374,6 +2445,22 @@ _No fields._ ], "tracked": true }, + "lists.captureTemplate": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.clearLevelOverrides": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "lists.continuePrevious": { "available": true, "dryRun": true, @@ -2470,6 +2557,54 @@ _No fields._ ], "tracked": true }, + "lists.setLevelAlignment": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.setLevelBullet": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.setLevelIndents": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.setLevelMarkerFont": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.setLevelNumbering": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.setLevelPictureBullet": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "lists.setLevelRestart": { "available": true, "dryRun": true, @@ -2478,6 +2613,14 @@ _No fields._ ], "tracked": true }, + "lists.setLevelTrailingCharacter": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "lists.setValue": { "available": true, "dryRun": true, @@ -8264,6 +8407,76 @@ _No fields._ ], "type": "object" }, + "lists.applyPreset": { + "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": { + "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.attach": { "additionalProperties": false, "properties": { @@ -8369,6 +8582,76 @@ _No fields._ ], "type": "object" }, + "lists.captureTemplate": { + "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.clearLevelOverrides": { + "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.continuePrevious": { "additionalProperties": false, "properties": { @@ -8789,6 +9072,216 @@ _No fields._ ], "type": "object" }, + "lists.setLevelAlignment": { + "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.setLevelBullet": { + "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.setLevelIndents": { + "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": { + "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.setLevelNumbering": { + "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": { + "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.setLevelRestart": { "additionalProperties": false, "properties": { @@ -8824,6 +9317,41 @@ _No fields._ ], "type": "object" }, + "lists.setLevelTrailingCharacter": { + "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.setValue": { "additionalProperties": false, "properties": { @@ -11774,6 +12302,17 @@ _No fields._ "lists.canContinuePrevious", "lists.setLevelRestart", "lists.convertToText", + "lists.applyTemplate", + "lists.applyPreset", + "lists.captureTemplate", + "lists.setLevelNumbering", + "lists.setLevelBullet", + "lists.setLevelPictureBullet", + "lists.setLevelAlignment", + "lists.setLevelIndents", + "lists.setLevelTrailingCharacter", + "lists.setLevelMarkerFont", + "lists.clearLevelOverrides", "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 2965a67cfd..9e1790c953 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 | 17 | 0 | 17 | [Open](/document-api/reference/lists/index) | +| Lists | 28 | 1 | 29 | [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) | @@ -181,6 +181,18 @@ 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.applyPreset | editor.doc.lists.applyPreset(...) | Apply a built-in list formatting preset to the target list. | +| 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.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. | +| lists.setLevelIndents | editor.doc.lists.setLevelIndents(...) | Set the paragraph indentation values (left, hanging, firstLine) for a specific list level. | +| 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.setType | editor.doc.lists.setType(...) | Convenience wrapper that maps a simple kind (ordered/bullet) to the default preset via `lists.applyPreset`. | #### Comments diff --git a/apps/docs/document-api/reference/lists/apply-preset.mdx b/apps/docs/document-api/reference/lists/apply-preset.mdx new file mode 100644 index 0000000000..fda45fb278 --- /dev/null +++ b/apps/docs/document-api/reference/lists/apply-preset.mdx @@ -0,0 +1,264 @@ +--- +title: lists.applyPreset +sidebarTitle: lists.applyPreset +description: Apply a built-in list formatting preset to the target list. +--- + +{/* 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 built-in list formatting preset to the target list. + +- Operation ID: `lists.applyPreset` +- API member path: `editor.doc.lists.applyPreset(...)` +- 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 the preset. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `levels` | integer[] | no | | +| `preset` | enum | yes | `"decimal"`, `"decimalParenthesis"`, `"lowerLetter"`, `"upperLetter"`, `"lowerRoman"`, `"upperRoman"`, `"disc"`, `"circle"`, `"square"`, `"dash"` | +| `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 + ], + "preset": "decimal", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"LEVEL_OUT_OF_RANGE"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `INVALID_INPUT` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "preset": { + "enum": [ + "decimal", + "decimalParenthesis", + "lowerLetter", + "upperLetter", + "lowerRoman", + "upperRoman", + "disc", + "circle", + "square", + "dash" + ] + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "preset" + ], + "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", + "LEVEL_OUT_OF_RANGE", + "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", + "LEVEL_OUT_OF_RANGE", + "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/apply-template.mdx b/apps/docs/document-api/reference/lists/apply-template.mdx new file mode 100644 index 0000000000..8a91993f0b --- /dev/null +++ b/apps/docs/document-api/reference/lists/apply-template.mdx @@ -0,0 +1,335 @@ +--- +title: lists.applyTemplate +sidebarTitle: lists.applyTemplate +description: Apply a captured ListTemplate to the target list, optionally filtered to specific levels. +--- + +{/* 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 captured ListTemplate to the target list, optionally filtered to specific levels. + +- Operation ID: `lists.applyTemplate` +- API member path: `editor.doc.lists.applyTemplate(...)` +- 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 | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `template` | object | yes | | +| `template.levels` | object[] | yes | | +| `template.version` | `1` | yes | Constant: `1` | + +### Example request + +```json +{ + "levels": [ + 1 + ], + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "template": { + "levels": [ + { + "level": 1, + "lvlText": "example", + "numFmt": "example" + } + ], + "version": 1 + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"LEVEL_OUT_OF_RANGE"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `INVALID_INPUT` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "levels": { + "items": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + }, + "template": { + "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" + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "levels" + ], + "type": "object" + } + }, + "required": [ + "target", + "template" + ], + "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", + "LEVEL_OUT_OF_RANGE", + "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", + "LEVEL_OUT_OF_RANGE", + "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/capture-template.mdx b/apps/docs/document-api/reference/lists/capture-template.mdx new file mode 100644 index 0000000000..4b38b91a21 --- /dev/null +++ b/apps/docs/document-api/reference/lists/capture-template.mdx @@ -0,0 +1,389 @@ +--- +title: lists.captureTemplate +sidebarTitle: lists.captureTemplate +description: Capture the formatting of a list as a reusable ListTemplate. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Capture the formatting of a list as a reusable ListTemplate. + +- Operation ID: `lists.captureTemplate` +- API member path: `editor.doc.lists.captureTemplate(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsCaptureTemplateResult containing the captured template. + +## 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 | +| --- | --- | --- | --- | +| `success` | `true` | yes | Constant: `true` | +| `template` | object | yes | | +| `template.levels` | object[] | yes | | +| `template.version` | `1` | yes | Constant: `1` | + +### 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 +{ + "success": true, + "template": { + "levels": [ + { + "level": 1, + "lvlText": "example", + "numFmt": "example" + } + ], + "version": 1 + } +} +``` + +## 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": { + "success": { + "const": true + }, + "template": { + "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" + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "levels" + ], + "type": "object" + } + }, + "required": [ + "success", + "template" + ], + "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": { + "success": { + "const": true + }, + "template": { + "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" + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "levels" + ], + "type": "object" + } + }, + "required": [ + "success", + "template" + ], + "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/clear-level-overrides.mdx b/apps/docs/document-api/reference/lists/clear-level-overrides.mdx new file mode 100644 index 0000000000..6d13eb2a0a --- /dev/null +++ b/apps/docs/document-api/reference/lists/clear-level-overrides.mdx @@ -0,0 +1,239 @@ +--- +title: lists.clearLevelOverrides +sidebarTitle: lists.clearLevelOverrides +description: Remove instance-level overrides for a specific list level, restoring abstract definition values. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Remove instance-level overrides for a specific list level, restoring abstract definition values. + +- Operation ID: `lists.clearLevelOverrides` +- API member path: `editor.doc.lists.clearLevelOverrides(...)` +- 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 no override exists. + +## 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"` | + +### Example request + +```json +{ + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"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` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `LEVEL_OUT_OF_RANGE` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level" + ], + "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", + "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", + "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 1a49fa2f54..fb5376997d 100644 --- a/apps/docs/document-api/reference/lists/index.mdx +++ b/apps/docs/document-api/reference/lists/index.mdx @@ -31,4 +31,22 @@ List inspection and list mutations. | lists.canContinuePrevious | `lists.canContinuePrevious` | No | `idempotent` | No | No | | lists.setLevelRestart | `lists.setLevelRestart` | Yes | `conditional` | No | Yes | | lists.convertToText | `lists.convertToText` | Yes | `conditional` | No | Yes | +| lists.applyTemplate | `lists.applyTemplate` | Yes | `conditional` | No | Yes | +| lists.applyPreset | `lists.applyPreset` | Yes | `conditional` | No | Yes | +| lists.captureTemplate | `lists.captureTemplate` | No | `idempotent` | No | No | +| lists.setLevelNumbering | `lists.setLevelNumbering` | Yes | `conditional` | No | Yes | +| lists.setLevelBullet | `lists.setLevelBullet` | Yes | `conditional` | No | Yes | +| lists.setLevelPictureBullet | `lists.setLevelPictureBullet` | Yes | `conditional` | No | Yes | +| lists.setLevelAlignment | `lists.setLevelAlignment` | Yes | `conditional` | No | Yes | +| lists.setLevelIndents | `lists.setLevelIndents` | Yes | `conditional` | No | Yes | +| lists.setLevelTrailingCharacter | `lists.setLevelTrailingCharacter` | Yes | `conditional` | No | Yes | +| lists.setLevelMarkerFont | `lists.setLevelMarkerFont` | Yes | `conditional` | No | Yes | +| lists.clearLevelOverrides | `lists.clearLevelOverrides` | Yes | `conditional` | No | Yes | + + +## Convenience aliases + +| Alias method | Canonical operation | Behavior | +| --- | --- | --- | +| `editor.doc.lists.setType(...)` | lists.applyPreset | Convenience wrapper that maps a simple kind (ordered/bullet) to the default preset via `lists.applyPreset`. | diff --git a/apps/docs/document-api/reference/lists/set-level-alignment.mdx b/apps/docs/document-api/reference/lists/set-level-alignment.mdx new file mode 100644 index 0000000000..cba39d499f --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-alignment.mdx @@ -0,0 +1,252 @@ +--- +title: lists.setLevelAlignment +sidebarTitle: lists.setLevelAlignment +description: Set the marker alignment (left, center, right) for a specific list level. +--- + +{/* 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 marker alignment (left, center, right) for a specific list level. + +- Operation ID: `lists.setLevelAlignment` +- API member path: `editor.doc.lists.setLevelAlignment(...)` +- 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 alignment already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `alignment` | enum | yes | `"left"`, `"center"`, `"right"` | +| `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 +{ + "alignment": "left", + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "alignment": { + "enum": [ + "left", + "center", + "right" + ] + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "alignment" + ], + "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", + "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", + "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-bullet.mdx b/apps/docs/document-api/reference/lists/set-level-bullet.mdx new file mode 100644 index 0000000000..b3ac100d0e --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-bullet.mdx @@ -0,0 +1,248 @@ +--- +title: lists.setLevelBullet +sidebarTitle: lists.setLevelBullet +description: Set the bullet marker text for a specific list level. +--- + +{/* 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 bullet marker text for a specific list level. + +- Operation ID: `lists.setLevelBullet` +- API member path: `editor.doc.lists.setLevelBullet(...)` +- 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 marker already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `markerText` | 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, + "markerText": "example", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "markerText": { + "type": "string" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "markerText" + ], + "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", + "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", + "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-indents.mdx b/apps/docs/document-api/reference/lists/set-level-indents.mdx new file mode 100644 index 0000000000..7650a4ce2a --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-indents.mdx @@ -0,0 +1,285 @@ +--- +title: lists.setLevelIndents +sidebarTitle: lists.setLevelIndents +description: Set the paragraph indentation values (left, hanging, firstLine) for a specific list level. +--- + +{/* 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 paragraph indentation values (left, hanging, firstLine) for a specific list level. + +- Operation ID: `lists.setLevelIndents` +- API member path: `editor.doc.lists.setLevelIndents(...)` +- 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 indent values already match. + +## Input fields + +### Variant 1 + +_No fields._ + +### Variant 2 + +_No fields._ + +### Variant 3 + +_No fields._ + +### Example request + +```json +{ + "firstLine": 1, + "hanging": 1, + "left": 1, + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"LEVEL_OUT_OF_RANGE"`, `"LEVEL_NOT_FOUND"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` +- `INVALID_INPUT` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "left" + ] + }, + { + "required": [ + "hanging" + ] + }, + { + "required": [ + "firstLine" + ] + } + ], + "not": { + "required": [ + "hanging", + "firstLine" + ] + }, + "properties": { + "firstLine": { + "type": "integer" + }, + "hanging": { + "type": "integer" + }, + "left": { + "type": "integer" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level" + ], + "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", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND", + "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", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND", + "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-marker-font.mdx b/apps/docs/document-api/reference/lists/set-level-marker-font.mdx new file mode 100644 index 0000000000..838ef02bc5 --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-marker-font.mdx @@ -0,0 +1,248 @@ +--- +title: lists.setLevelMarkerFont +sidebarTitle: lists.setLevelMarkerFont +description: Set the font family used for the marker character at a specific list level. +--- + +{/* 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 font family used for the marker character at a specific list level. + +- Operation ID: `lists.setLevelMarkerFont` +- API member path: `editor.doc.lists.setLevelMarkerFont(...)` +- 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 font already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `fontFamily` | string | yes | | +| `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 +{ + "fontFamily": "example", + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "fontFamily": { + "type": "string" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "fontFamily" + ], + "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", + "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", + "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 new file mode 100644 index 0000000000..0ae4c6a77f --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-numbering.mdx @@ -0,0 +1,259 @@ +--- +title: lists.setLevelNumbering +sidebarTitle: lists.setLevelNumbering +description: Set the numbering format, pattern, and optional start value for a specific list level. +--- + +{/* 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 format, pattern, and optional start value for a specific list level. + +- Operation ID: `lists.setLevelNumbering` +- API member path: `editor.doc.lists.setLevelNumbering(...)` +- 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 level already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `lvlText` | string | yes | | +| `numFmt` | string | yes | | +| `start` | 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 +{ + "level": 1, + "lvlText": "example", + "numFmt": "example", + "start": 0, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "lvlText": { + "type": "string" + }, + "numFmt": { + "type": "string" + }, + "start": { + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "numFmt", + "lvlText" + ], + "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", + "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", + "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-picture-bullet.mdx b/apps/docs/document-api/reference/lists/set-level-picture-bullet.mdx new file mode 100644 index 0000000000..23bf4b24ac --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-picture-bullet.mdx @@ -0,0 +1,255 @@ +--- +title: lists.setLevelPictureBullet +sidebarTitle: lists.setLevelPictureBullet +description: Set a picture bullet for a specific list level by its OOXML lvlPicBulletId. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set a picture bullet for a specific list level by its OOXML lvlPicBulletId. + +- Operation ID: `lists.setLevelPictureBullet` +- API member path: `editor.doc.lists.setLevelPictureBullet(...)` +- 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 picture bullet already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `pictureBulletId` | 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, + "pictureBulletId": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"LEVEL_OUT_OF_RANGE"`, `"LEVEL_NOT_FOUND"`, `"INVALID_INPUT"`, `"CAPABILITY_UNAVAILABLE"` | +| `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` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` +- `INVALID_INPUT` +- `CAPABILITY_UNAVAILABLE` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "pictureBulletId": { + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "pictureBulletId" + ], + "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", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND", + "INVALID_INPUT", + "CAPABILITY_UNAVAILABLE" + ] + }, + "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", + "LEVEL_OUT_OF_RANGE", + "LEVEL_NOT_FOUND", + "INVALID_INPUT", + "CAPABILITY_UNAVAILABLE" + ] + }, + "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-trailing-character.mdx b/apps/docs/document-api/reference/lists/set-level-trailing-character.mdx new file mode 100644 index 0000000000..1c1aa1c7e4 --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-trailing-character.mdx @@ -0,0 +1,252 @@ +--- +title: lists.setLevelTrailingCharacter +sidebarTitle: lists.setLevelTrailingCharacter +description: Set the trailing character (tab, space, nothing) after the marker for a specific list level. +--- + +{/* 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 trailing character (tab, space, nothing) after the marker for a specific list level. + +- Operation ID: `lists.setLevelTrailingCharacter` +- API member path: `editor.doc.lists.setLevelTrailingCharacter(...)` +- 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 trailing character 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"` | +| `trailingCharacter` | enum | yes | `"tab"`, `"space"`, `"nothing"` | + +### Example request + +```json +{ + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "trailingCharacter": "tab" +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| 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"`, `"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` +- `LEVEL_OUT_OF_RANGE` +- `LEVEL_NOT_FOUND` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + }, + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] + } + }, + "required": [ + "target", + "level", + "trailingCharacter" + ], + "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", + "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", + "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 6737703e8b..ea23388889 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -526,6 +526,17 @@ 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.applyPreset` | `lists apply-preset` | Apply a built-in list formatting preset to the target list. | +| `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.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. | +| `doc.lists.setLevelIndents` | `lists set-level-indents` | Set the paragraph indentation values (left, hanging, firstLine) for a specific list level. | +| `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. | #### Tables @@ -835,6 +846,17 @@ 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_preset` | `lists apply-preset` | Apply a built-in list formatting preset to the target list. | +| `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.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. | +| `doc.lists.set_level_indents` | `lists set-level-indents` | Set the paragraph indentation values (left, hanging, firstLine) for a specific list level. | +| `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. | #### Tables diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts index 6b6659c906..ad7662f834 100644 --- a/packages/document-api/scripts/check-contract-parity.ts +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -145,32 +145,109 @@ function createNoopAdapters(): DocumentApiAdapters { list: () => ({ evaluatedRevision: '', total: 0, items: [], page: { limit: 50, offset: 0, returned: 0 } }), get: () => ({ address: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + listId: 'list-1', }), insert: () => ({ success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, insertionPoint: { kind: 'text', blockId: 'li-2', range: { start: 0, end: 0 } }, }), - setType: () => ({ + indent: () => ({ success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, }), - indent: () => ({ + outdent: () => ({ success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, }), - outdent: () => ({ + create: () => ({ + success: true, + listId: 'list-new', + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-new' }, + }), + attach: () => ({ success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, }), - restart: () => ({ + detach: () => ({ + success: true, + paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: 'p3' }, + }), + join: () => ({ + success: true, + listId: 'list-1', + }), + canJoin: () => ({ canJoin: true }), + separate: () => ({ + success: true, + listId: 'list-new', + numId: 2, + }), + setLevel: () => ({ success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, }), - exit: () => ({ + setValue: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + continuePrevious: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + canContinuePrevious: () => ({ canContinue: true }), + setLevelRestart: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + convertToText: () => ({ success: true, paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: 'p3' }, }), + applyTemplate: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + applyPreset: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + captureTemplate: () => ({ + success: true, + template: { version: 1, levels: [] }, + }), + setLevelNumbering: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + setLevelBullet: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + setLevelPictureBullet: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + setLevelAlignment: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + setLevelIndents: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + setLevelTrailingCharacter: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + setLevelMarkerFont: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + clearLevelOverrides: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), }, }; } diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index c3d32cb261..db2e4d095f 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -493,6 +493,134 @@ Convert list items to plain paragraphs. When `includeMarker` is true, prepends t - **Idempotency**: conditional - **Failure codes**: `INVALID_TARGET` +### `lists.applyTemplate` + +Apply a captured `ListTemplate` to the target list's abstract definition, optionally filtered to specific levels. Direct-only. Supports dry-run. + +- **Input**: `ListsApplyTemplateInput` (`{ target, template, levels? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `INVALID_INPUT` + +### `lists.applyPreset` + +Apply a built-in list formatting preset (e.g. `decimal`, `disc`, `upperRoman`) to the target list. Direct-only. Supports dry-run. + +- **Input**: `ListsApplyPresetInput` (`{ target, preset, levels? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `INVALID_INPUT` + +### `lists.captureTemplate` + +Capture the formatting of a list as a reusable `ListTemplate`. Read-only operation. + +- **Input**: `ListsCaptureTemplateInput` (`{ target, levels? }`) +- **Output**: `ListsCaptureTemplateResult` (`{ success, template }` | `{ success: false, failure }`) +- **Mutates**: No +- **Idempotency**: idempotent +- **Failure codes**: `INVALID_TARGET`, `INVALID_INPUT` + +### `lists.setLevelNumbering` + +Set the numbering format (`numFmt`), pattern (`lvlText`), and optional start value for a specific list level. Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelNumberingInput` (`{ target, level, numFmt, lvlText, start? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` + +### `lists.setLevelBullet` + +Set the bullet marker text for a specific list level. Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelBulletInput` (`{ target, level, markerText }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` + +### `lists.setLevelPictureBullet` + +Set a picture bullet for a specific list level by its OOXML `lvlPicBulletId`. Requires picture bullet pipeline support. Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelPictureBulletInput` (`{ target, level, pictureBulletId }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND`, `INVALID_INPUT`, `CAPABILITY_UNAVAILABLE` + +### `lists.setLevelAlignment` + +Set the marker alignment (`left`, `center`, `right`) for a specific list level. Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelAlignmentInput` (`{ target, level, alignment }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` + +### `lists.setLevelIndents` + +Set the paragraph indentation values (`left`, `hanging`, `firstLine`) for a specific list level. At least one property required; `hanging` and `firstLine` are mutually exclusive. Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelIndentsInput` (`{ target, level, left?, hanging?, firstLine? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND`, `INVALID_INPUT` + +### `lists.setLevelTrailingCharacter` + +Set the trailing character (`tab`, `space`, `nothing`) after the marker for a specific list level. Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelTrailingCharacterInput` (`{ target, level, trailingCharacter }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` + +### `lists.setLevelMarkerFont` + +Set the font family used for the marker character at a specific list level. Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelMarkerFontInput` (`{ target, level, fontFamily }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` + +### `lists.clearLevelOverrides` + +Remove instance-level overrides (`lvlOverride`) for a specific list level, restoring abstract definition values. Direct-only. Supports dry-run. + +- **Input**: `ListsClearLevelOverridesInput` (`{ target, level }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` + +### `lists.setType` (convenience) + +Convenience wrapper that maps `'ordered'` / `'bullet'` to the corresponding default preset via `lists.applyPreset`. Not a canonical operation — it is a reference alias for `lists.applyPreset`. + +- **Input**: `{ target, kind: 'ordered' | 'bullet' }` +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` + ### Comments ### `comments.create` diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 7c211e7492..4b950d8dde 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -235,6 +235,10 @@ describe('document-api contract catalog', () => { } }); + it('prevents lists.setType from becoming a canonical operation ID', () => { + expect(OPERATION_IDS).not.toContain('lists.setType'); + }); + it('marks exactly the out-of-band mutation operations as historyUnsafe', () => { const historyUnsafeOps = OPERATION_IDS.filter((id) => COMMAND_CATALOG[id].historyUnsafe === true).sort(); diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index ee97c1764b..de202f50f4 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -77,6 +77,7 @@ function readOperation( options: { idempotency?: OperationIdempotency; throws?: readonly PreApplyThrowCode[]; + possibleFailureCodes?: readonly ReceiptFailureCode[]; deterministicTargetResolution?: boolean; remediationHints?: readonly string[]; } = {}, @@ -86,7 +87,7 @@ function readOperation( idempotency: options.idempotency ?? 'idempotent', supportsDryRun: false, supportsTrackedMode: false, - possibleFailureCodes: NONE_FAILURES, + possibleFailureCodes: options.possibleFailureCodes ?? NONE_FAILURES, throws: { preApply: options.throws ?? NONE_THROWS, postApplyForbidden: true, @@ -1276,6 +1277,178 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'lists', }, + // 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.', + 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', 'LEVEL_OUT_OF_RANGE', 'INVALID_INPUT'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/apply-template.mdx', + referenceGroup: 'lists', + }, + 'lists.applyPreset': { + memberPath: 'lists.applyPreset', + description: 'Apply a built-in list formatting preset to the target list.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if all levels already match the preset.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'INVALID_INPUT'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/apply-preset.mdx', + referenceGroup: 'lists', + }, + 'lists.captureTemplate': { + memberPath: 'lists.captureTemplate', + description: 'Capture the formatting of a list as a reusable ListTemplate.', + expectedResult: 'Returns a ListsCaptureTemplateResult containing the captured template.', + 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/capture-template.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelNumbering': { + memberPath: 'lists.setLevelNumbering', + description: 'Set the numbering format, pattern, and optional start value for a specific list level.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the level already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-level-numbering.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelBullet': { + memberPath: 'lists.setLevelBullet', + description: 'Set the bullet marker text for a specific list level.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the marker already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-level-bullet.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelPictureBullet': { + memberPath: 'lists.setLevelPictureBullet', + description: 'Set a picture bullet for a specific list level by its OOXML lvlPicBulletId.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the picture bullet already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: [ + 'NO_OP', + 'INVALID_TARGET', + 'LEVEL_OUT_OF_RANGE', + 'LEVEL_NOT_FOUND', + 'INVALID_INPUT', + 'CAPABILITY_UNAVAILABLE', + ], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], + }), + referenceDocPath: 'lists/set-level-picture-bullet.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelAlignment': { + memberPath: 'lists.setLevelAlignment', + description: 'Set the marker alignment (left, center, right) for a specific list level.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the alignment already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-level-alignment.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelIndents': { + memberPath: 'lists.setLevelIndents', + description: 'Set the paragraph indentation values (left, hanging, firstLine) for a specific list level.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if all indent values already match.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND', 'INVALID_INPUT'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/set-level-indents.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelTrailingCharacter': { + memberPath: 'lists.setLevelTrailingCharacter', + description: 'Set the trailing character (tab, space, nothing) after the marker for a specific list level.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the trailing character already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-level-trailing-character.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelMarkerFont': { + memberPath: 'lists.setLevelMarkerFont', + description: 'Set the font family used for the marker character at a specific list level.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if the font already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'LEVEL_NOT_FOUND'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-level-marker-font.mdx', + referenceGroup: 'lists', + }, + 'lists.clearLevelOverrides': { + memberPath: 'lists.clearLevelOverrides', + description: 'Remove instance-level overrides for a specific list level, restoring abstract definition values.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if no override exists.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/clear-level-overrides.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 5d0ebb1694..c0e316dec6 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -71,6 +71,18 @@ import type { ListsSetLevelRestartInput, ListsConvertToTextInput, ListsConvertToTextResult, + ListsApplyTemplateInput, + ListsApplyPresetInput, + ListsCaptureTemplateInput, + ListsCaptureTemplateResult, + ListsSetLevelNumberingInput, + ListsSetLevelBulletInput, + ListsSetLevelPictureBulletInput, + ListsSetLevelAlignmentInput, + ListsSetLevelIndentsInput, + ListsSetLevelTrailingCharacterInput, + ListsSetLevelMarkerFontInput, + ListsClearLevelOverridesInput, } from '../lists/lists.types.js'; import type { ParagraphMutationResult, @@ -384,6 +396,47 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { }; 'lists.convertToText': { input: ListsConvertToTextInput; options: MutationOptions; output: ListsConvertToTextResult }; + // --- lists.* (SD-1973 formatting) --- + 'lists.applyTemplate': { input: ListsApplyTemplateInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.applyPreset': { input: ListsApplyPresetInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.captureTemplate': { input: ListsCaptureTemplateInput; options: never; output: ListsCaptureTemplateResult }; + 'lists.setLevelNumbering': { + input: ListsSetLevelNumberingInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.setLevelBullet': { input: ListsSetLevelBulletInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.setLevelPictureBullet': { + input: ListsSetLevelPictureBulletInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.setLevelAlignment': { + input: ListsSetLevelAlignmentInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.setLevelIndents': { + input: ListsSetLevelIndentsInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.setLevelTrailingCharacter': { + input: ListsSetLevelTrailingCharacterInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.setLevelMarkerFont': { + input: ListsSetLevelMarkerFontInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.clearLevelOverrides': { + input: ListsClearLevelOverridesInput; + 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/reference-aliases.ts b/packages/document-api/src/contract/reference-aliases.ts index e3eeef90e9..14af1761bb 100644 --- a/packages/document-api/src/contract/reference-aliases.ts +++ b/packages/document-api/src/contract/reference-aliases.ts @@ -25,4 +25,11 @@ export const REFERENCE_OPERATION_ALIASES: readonly ReferenceAliasDefinition[] = referenceGroup: 'format', description: 'Convenience alias for `format.strike` with `value: true`.', }, + { + memberPath: 'lists.setType', + canonicalOperationId: 'lists.applyPreset', + referenceGroup: 'lists', + description: + 'Convenience wrapper that maps a simple kind (ordered/bullet) to the default preset via `lists.applyPreset`.', + }, ] as const; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 7af7d8168b..3364b956f5 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2501,6 +2501,230 @@ const operationSchemas: Record = { success: objectSchema({ success: { const: true }, paragraph: ref('ParagraphAddress') }, ['success', 'paragraph']), failure: listsFailureSchemaFor('lists.convertToText'), }, + + // SD-1973 — List formatting and templates + 'lists.applyTemplate': { + input: objectSchema( + { + target: listItemAddressSchema, + template: 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' }, + }, + ['level'], + ), + ), + }, + ['version', 'levels'], + ), + levels: arraySchema({ type: 'integer', minimum: 0, maximum: 8 }), + }, + ['target', 'template'], + ), + output: listsMutateItemResultSchemaFor('lists.applyTemplate'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.applyTemplate'), + }, + 'lists.applyPreset': { + input: objectSchema( + { + target: listItemAddressSchema, + preset: { + enum: [ + 'decimal', + 'decimalParenthesis', + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'disc', + 'circle', + 'square', + 'dash', + ], + }, + levels: arraySchema({ type: 'integer', minimum: 0, maximum: 8 }), + }, + ['target', 'preset'], + ), + output: listsMutateItemResultSchemaFor('lists.applyPreset'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.applyPreset'), + }, + 'lists.captureTemplate': (() => { + const successSchema = objectSchema( + { + success: { const: true }, + template: 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' }, + }, + ['level'], + ), + ), + }, + ['version', 'levels'], + ), + }, + ['success', 'template'], + ); + return { + input: objectSchema( + { + target: listItemAddressSchema, + levels: arraySchema({ type: 'integer', minimum: 0, maximum: 8 }), + }, + ['target'], + ), + output: { oneOf: [successSchema, listsFailureSchemaFor('lists.captureTemplate')] }, + success: successSchema, + failure: listsFailureSchemaFor('lists.captureTemplate'), + }; + })(), + 'lists.setLevelNumbering': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + numFmt: { type: 'string' }, + lvlText: { type: 'string' }, + start: { type: 'integer' }, + }, + ['target', 'level', 'numFmt', 'lvlText'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelNumbering'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelNumbering'), + }, + 'lists.setLevelBullet': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + markerText: { type: 'string' }, + }, + ['target', 'level', 'markerText'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelBullet'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelBullet'), + }, + 'lists.setLevelPictureBullet': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + pictureBulletId: { type: 'integer' }, + }, + ['target', 'level', 'pictureBulletId'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelPictureBullet'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelPictureBullet'), + }, + 'lists.setLevelAlignment': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + alignment: { enum: ['left', 'center', 'right'] }, + }, + ['target', 'level', 'alignment'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelAlignment'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelAlignment'), + }, + 'lists.setLevelIndents': { + input: { + type: 'object', + properties: { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + left: { type: 'integer' }, + hanging: { type: 'integer' }, + firstLine: { type: 'integer' }, + }, + required: ['target', 'level'], + additionalProperties: false, + anyOf: [{ required: ['left'] }, { required: ['hanging'] }, { required: ['firstLine'] }], + not: { required: ['hanging', 'firstLine'] }, + }, + output: listsMutateItemResultSchemaFor('lists.setLevelIndents'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelIndents'), + }, + 'lists.setLevelTrailingCharacter': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + trailingCharacter: { enum: ['tab', 'space', 'nothing'] }, + }, + ['target', 'level', 'trailingCharacter'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelTrailingCharacter'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelTrailingCharacter'), + }, + 'lists.setLevelMarkerFont': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + fontFamily: { type: 'string' }, + }, + ['target', 'level', 'fontFamily'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelMarkerFont'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelMarkerFont'), + }, + 'lists.clearLevelOverrides': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + }, + ['target', 'level'], + ), + output: listsMutateItemResultSchemaFor('lists.clearLevelOverrides'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.clearLevelOverrides'), + }, + 'comments.create': { input: objectSchema( { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 133d306d6a..ea251d7876 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -103,6 +103,18 @@ import type { ListsSetLevelRestartInput, ListsConvertToTextInput, ListsConvertToTextResult, + ListsApplyTemplateInput, + ListsApplyPresetInput, + ListsCaptureTemplateInput, + ListsCaptureTemplateResult, + ListsSetLevelNumberingInput, + ListsSetLevelBulletInput, + ListsSetLevelPictureBulletInput, + ListsSetLevelAlignmentInput, + ListsSetLevelIndentsInput, + ListsSetLevelTrailingCharacterInput, + ListsSetLevelMarkerFontInput, + ListsClearLevelOverridesInput, } from './lists/lists.types.js'; import { executeListsGet, @@ -122,6 +134,17 @@ import { executeListsCanContinuePrevious, executeListsSetLevelRestart, executeListsConvertToText, + executeListsApplyTemplate, + executeListsApplyPreset, + executeListsCaptureTemplate, + executeListsSetLevelNumbering, + executeListsSetLevelBullet, + executeListsSetLevelPictureBullet, + executeListsSetLevelAlignment, + executeListsSetLevelIndents, + executeListsSetLevelTrailingCharacter, + executeListsSetLevelMarkerFont, + executeListsClearLevelOverrides, } from './lists/lists.js'; import { executeReplace, type ReplaceInput } from './replace/replace.js'; import type { CreateAdapter, CreateApi } from './create/create.js'; @@ -612,8 +635,34 @@ export type { ListsSetValueInput, ListTargetInput, MutationScope, + LevelAlignment, + TrailingCharacter, + ListPresetId, + ListLevelTemplate, + ListTemplate, + ListsApplyTemplateInput, + ListsApplyPresetInput, + ListsCaptureTemplateInput, + ListsCaptureTemplateResult, + ListsCaptureTemplateSuccessResult, + ListsSetLevelNumberingInput, + ListsSetLevelBulletInput, + ListsSetLevelPictureBulletInput, + ListsSetLevelAlignmentInput, + ListsSetLevelIndentsInput, + ListsSetLevelTrailingCharacterInput, + ListsSetLevelMarkerFontInput, + ListsClearLevelOverridesInput, +} from './lists/lists.types.js'; +export { + LIST_KINDS, + LIST_INSERT_POSITIONS, + JOIN_DIRECTIONS, + MUTATION_SCOPES, + LEVEL_ALIGNMENTS, + TRAILING_CHARACTERS, + LIST_PRESET_IDS, } from './lists/lists.types.js'; -export { LIST_KINDS, LIST_INSERT_POSITIONS, JOIN_DIRECTIONS, MUTATION_SCOPES } from './lists/lists.types.js'; export type { CreateSectionBreakInput, CreateSectionBreakResult, @@ -1210,6 +1259,60 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { convertToText(input: ListsConvertToTextInput, options?: MutationOptions): ListsConvertToTextResult { return executeListsConvertToText(adapters.lists, input, options); }, + + // SD-1973 formatting operations + applyTemplate(input: ListsApplyTemplateInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsApplyTemplate(adapters.lists, input, options); + }, + applyPreset(input: ListsApplyPresetInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsApplyPreset(adapters.lists, input, options); + }, + captureTemplate(input: ListsCaptureTemplateInput): ListsCaptureTemplateResult { + return executeListsCaptureTemplate(adapters.lists, input); + }, + setLevelNumbering(input: ListsSetLevelNumberingInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelNumbering(adapters.lists, input, options); + }, + setLevelBullet(input: ListsSetLevelBulletInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelBullet(adapters.lists, input, options); + }, + setLevelPictureBullet(input: ListsSetLevelPictureBulletInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelPictureBullet(adapters.lists, input, options); + }, + setLevelAlignment(input: ListsSetLevelAlignmentInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelAlignment(adapters.lists, input, options); + }, + setLevelIndents(input: ListsSetLevelIndentsInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelIndents(adapters.lists, input, options); + }, + setLevelTrailingCharacter( + input: ListsSetLevelTrailingCharacterInput, + options?: MutationOptions, + ): ListsMutateItemResult { + return executeListsSetLevelTrailingCharacter(adapters.lists, input, options); + }, + setLevelMarkerFont(input: ListsSetLevelMarkerFontInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelMarkerFont(adapters.lists, input, options); + }, + clearLevelOverrides(input: ListsClearLevelOverridesInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsClearLevelOverrides(adapters.lists, input, options); + }, + + // Convenience wrapper — not a canonical operation + setType( + input: { target: ListsApplyPresetInput['target']; kind: 'ordered' | 'bullet' }, + options?: MutationOptions, + ): ListsMutateItemResult { + const presetMap: Record = { + ordered: 'decimal', + bullet: 'disc', + }; + return executeListsApplyPreset( + adapters.lists, + { target: input.target, preset: presetMap[input.kind] }, + options, + ); + }, }, sections: { list(query?: SectionsListQuery): SectionsListResult { diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 5f3f0336e5..a09bf694ba 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -134,36 +134,59 @@ function makeAdapters() { insertionPoint: { kind: 'text' as const, blockId: 'new-h', range: { start: 0, end: 0 } }, })), }; + const listsMutateResult = () => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + }); const listsAdapter: ListsAdapter = { list: vi.fn(() => ({ evaluatedRevision: '', total: 0, items: [], page: { limit: 50, offset: 0, returned: 0 } })), get: vi.fn(() => ({ address: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + listId: 'list-1', })), insert: vi.fn(() => ({ success: true as const, item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-2' }, insertionPoint: { kind: 'text' as const, blockId: 'li-2', range: { start: 0, end: 0 } }, })), - setType: vi.fn(() => ({ - success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, - })), - indent: vi.fn(() => ({ + indent: vi.fn(listsMutateResult), + outdent: vi.fn(listsMutateResult), + create: vi.fn(() => ({ success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + listId: 'list-new', + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-new' }, })), - outdent: vi.fn(() => ({ + attach: vi.fn(listsMutateResult), + detach: vi.fn(() => ({ success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p3' }, })), - restart: vi.fn(() => ({ + join: vi.fn(() => ({ success: true as const, listId: 'list-1' })), + canJoin: vi.fn(() => ({ canJoin: true })), + separate: vi.fn(() => ({ success: true as const, listId: 'list-new', numId: 2 })), + setLevel: vi.fn(listsMutateResult), + setValue: vi.fn(listsMutateResult), + continuePrevious: vi.fn(listsMutateResult), + canContinuePrevious: vi.fn(() => ({ canContinue: true })), + setLevelRestart: vi.fn(listsMutateResult), + convertToText: vi.fn(() => ({ success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p3' }, })), - exit: vi.fn(() => ({ + applyTemplate: vi.fn(listsMutateResult), + applyPreset: vi.fn(listsMutateResult), + captureTemplate: vi.fn(() => ({ success: true as const, - paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p3' }, + template: { version: 1, levels: [] }, })), + setLevelNumbering: vi.fn(listsMutateResult), + setLevelBullet: vi.fn(listsMutateResult), + setLevelPictureBullet: vi.fn(listsMutateResult), + setLevelAlignment: vi.fn(listsMutateResult), + setLevelIndents: vi.fn(listsMutateResult), + setLevelTrailingCharacter: vi.fn(listsMutateResult), + setLevelMarkerFont: vi.fn(listsMutateResult), + clearLevelOverrides: vi.fn(listsMutateResult), }; const queryAdapter = { diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index bdacc2e33f..50d1ecf65e 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -129,6 +129,19 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'lists.setLevelRestart': (input, options) => api.lists.setLevelRestart(input, options), 'lists.convertToText': (input, options) => api.lists.convertToText(input, options), + // --- lists.* (SD-1973 formatting) --- + 'lists.applyTemplate': (input, options) => api.lists.applyTemplate(input, options), + 'lists.applyPreset': (input, options) => api.lists.applyPreset(input, options), + 'lists.captureTemplate': (input) => api.lists.captureTemplate(input), + 'lists.setLevelNumbering': (input, options) => api.lists.setLevelNumbering(input, options), + 'lists.setLevelBullet': (input, options) => api.lists.setLevelBullet(input, options), + 'lists.setLevelPictureBullet': (input, options) => api.lists.setLevelPictureBullet(input, options), + 'lists.setLevelAlignment': (input, options) => api.lists.setLevelAlignment(input, options), + 'lists.setLevelIndents': (input, options) => api.lists.setLevelIndents(input, options), + 'lists.setLevelTrailingCharacter': (input, options) => api.lists.setLevelTrailingCharacter(input, options), + 'lists.setLevelMarkerFont': (input, options) => api.lists.setLevelMarkerFont(input, options), + 'lists.clearLevelOverrides': (input, options) => api.lists.clearLevelOverrides(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 07310c6e28..1df11440b4 100644 --- a/packages/document-api/src/lists/lists.ts +++ b/packages/document-api/src/lists/lists.ts @@ -29,6 +29,18 @@ import type { ListsSetLevelRestartInput, ListsConvertToTextInput, ListsConvertToTextResult, + ListsApplyTemplateInput, + ListsApplyPresetInput, + ListsCaptureTemplateInput, + ListsCaptureTemplateResult, + ListsSetLevelNumberingInput, + ListsSetLevelBulletInput, + ListsSetLevelPictureBulletInput, + ListsSetLevelAlignmentInput, + ListsSetLevelIndentsInput, + ListsSetLevelTrailingCharacterInput, + ListsSetLevelMarkerFontInput, + ListsClearLevelOverridesInput, } from './lists.types.js'; export type { @@ -59,6 +71,18 @@ export type { ListsSetLevelRestartInput, ListsConvertToTextInput, ListsConvertToTextResult, + ListsApplyTemplateInput, + ListsApplyPresetInput, + ListsCaptureTemplateInput, + ListsCaptureTemplateResult, + ListsSetLevelNumberingInput, + ListsSetLevelBulletInput, + ListsSetLevelPictureBulletInput, + ListsSetLevelAlignmentInput, + ListsSetLevelIndentsInput, + ListsSetLevelTrailingCharacterInput, + ListsSetLevelMarkerFontInput, + ListsClearLevelOverridesInput, } from './lists.types.js'; /** @@ -84,7 +108,7 @@ export interface ListsAdapter { indent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; outdent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; - // SD-1272 new operations + // SD-1272 operations create(input: ListsCreateInput, options?: MutationOptions): ListsCreateResult; attach(input: ListsAttachInput, options?: MutationOptions): ListsMutateItemResult; detach(input: ListsDetachInput, options?: MutationOptions): ListsDetachResult; @@ -97,9 +121,31 @@ export interface ListsAdapter { canContinuePrevious(input: ListsCanContinuePreviousInput): ListsCanContinuePreviousResult; setLevelRestart(input: ListsSetLevelRestartInput, options?: MutationOptions): ListsMutateItemResult; convertToText(input: ListsConvertToTextInput, options?: MutationOptions): ListsConvertToTextResult; + + // SD-1973 formatting operations + applyTemplate(input: ListsApplyTemplateInput, options?: MutationOptions): ListsMutateItemResult; + applyPreset(input: ListsApplyPresetInput, options?: MutationOptions): ListsMutateItemResult; + captureTemplate(input: ListsCaptureTemplateInput): ListsCaptureTemplateResult; + setLevelNumbering(input: ListsSetLevelNumberingInput, options?: MutationOptions): ListsMutateItemResult; + setLevelBullet(input: ListsSetLevelBulletInput, options?: MutationOptions): ListsMutateItemResult; + setLevelPictureBullet(input: ListsSetLevelPictureBulletInput, options?: MutationOptions): ListsMutateItemResult; + setLevelAlignment(input: ListsSetLevelAlignmentInput, options?: MutationOptions): ListsMutateItemResult; + setLevelIndents(input: ListsSetLevelIndentsInput, options?: MutationOptions): ListsMutateItemResult; + setLevelTrailingCharacter( + input: ListsSetLevelTrailingCharacterInput, + options?: MutationOptions, + ): ListsMutateItemResult; + setLevelMarkerFont(input: ListsSetLevelMarkerFontInput, options?: MutationOptions): ListsMutateItemResult; + clearLevelOverrides(input: ListsClearLevelOverridesInput, options?: MutationOptions): ListsMutateItemResult; } -export type ListsApi = ListsAdapter; +export type ListsApi = ListsAdapter & { + /** Convenience wrapper — maps ordered/bullet to the default preset via `lists.applyPreset`. */ + setType( + input: { target: ListsApplyPresetInput['target']; kind: 'ordered' | 'bullet' }, + options?: MutationOptions, + ): ListsMutateItemResult; +}; // --------------------------------------------------------------------------- // Execute wrappers — discovery @@ -145,7 +191,7 @@ export function executeListsOutdent( } // --------------------------------------------------------------------------- -// Execute wrappers — SD-1272 new operations +// Execute wrappers — SD-1272 operations // --------------------------------------------------------------------------- export function executeListsCreate( @@ -249,3 +295,105 @@ export function executeListsConvertToText( validateListTarget(input, 'lists.convertToText'); return adapter.convertToText(input, normalizeMutationOptions(options)); } + +// --------------------------------------------------------------------------- +// Execute wrappers — SD-1973 formatting operations +// --------------------------------------------------------------------------- + +export function executeListsApplyTemplate( + adapter: ListsAdapter, + input: ListsApplyTemplateInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.applyTemplate'); + return adapter.applyTemplate(input, normalizeMutationOptions(options)); +} + +export function executeListsApplyPreset( + adapter: ListsAdapter, + input: ListsApplyPresetInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.applyPreset'); + return adapter.applyPreset(input, normalizeMutationOptions(options)); +} + +export function executeListsCaptureTemplate( + adapter: ListsAdapter, + input: ListsCaptureTemplateInput, +): ListsCaptureTemplateResult { + validateListTarget(input, 'lists.captureTemplate'); + return adapter.captureTemplate(input); +} + +export function executeListsSetLevelNumbering( + adapter: ListsAdapter, + input: ListsSetLevelNumberingInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelNumbering'); + return adapter.setLevelNumbering(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelBullet( + adapter: ListsAdapter, + input: ListsSetLevelBulletInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelBullet'); + return adapter.setLevelBullet(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelPictureBullet( + adapter: ListsAdapter, + input: ListsSetLevelPictureBulletInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelPictureBullet'); + return adapter.setLevelPictureBullet(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelAlignment( + adapter: ListsAdapter, + input: ListsSetLevelAlignmentInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelAlignment'); + return adapter.setLevelAlignment(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelIndents( + adapter: ListsAdapter, + input: ListsSetLevelIndentsInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelIndents'); + return adapter.setLevelIndents(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelTrailingCharacter( + adapter: ListsAdapter, + input: ListsSetLevelTrailingCharacterInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelTrailingCharacter'); + return adapter.setLevelTrailingCharacter(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevelMarkerFont( + adapter: ListsAdapter, + input: ListsSetLevelMarkerFontInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelMarkerFont'); + return adapter.setLevelMarkerFont(input, normalizeMutationOptions(options)); +} + +export function executeListsClearLevelOverrides( + adapter: ListsAdapter, + input: ListsClearLevelOverridesInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.clearLevelOverrides'); + return adapter.clearLevelOverrides(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/lists/lists.types.ts b/packages/document-api/src/lists/lists.types.ts index 0e786f191f..99f0dfaa3f 100644 --- a/packages/document-api/src/lists/lists.types.ts +++ b/packages/document-api/src/lists/lists.types.ts @@ -39,10 +39,39 @@ export type ListInsertPosition = 'before' | 'after'; export type JoinDirection = 'withPrevious' | 'withNext'; export type MutationScope = 'definition' | 'instance'; +export type LevelAlignment = 'left' | 'center' | 'right'; +export type TrailingCharacter = 'tab' | 'space' | 'nothing'; + +export type ListPresetId = + | 'decimal' + | 'decimalParenthesis' + | 'lowerLetter' + | 'upperLetter' + | 'lowerRoman' + | 'upperRoman' + | 'disc' + | 'circle' + | 'square' + | 'dash'; + export const LIST_KINDS = ['ordered', 'bullet'] as const satisfies readonly ListKind[]; export const LIST_INSERT_POSITIONS = ['before', 'after'] as const satisfies readonly ListInsertPosition[]; export const JOIN_DIRECTIONS = ['withPrevious', 'withNext'] as const satisfies readonly JoinDirection[]; export const MUTATION_SCOPES = ['definition', 'instance'] as const satisfies readonly MutationScope[]; +export const LEVEL_ALIGNMENTS = ['left', 'center', 'right'] as const satisfies readonly LevelAlignment[]; +export const TRAILING_CHARACTERS = ['tab', 'space', 'nothing'] as const satisfies readonly TrailingCharacter[]; +export const LIST_PRESET_IDS = [ + 'decimal', + 'decimalParenthesis', + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'disc', + 'circle', + 'square', + 'dash', +] as const satisfies readonly ListPresetId[]; // --------------------------------------------------------------------------- // Failure code enums @@ -58,7 +87,9 @@ export type ListsFailureCode = | 'NO_ADJACENT_SEQUENCE' | 'ALREADY_SAME_SEQUENCE' | 'LEVEL_OUT_OF_RANGE' - | 'CAPABILITY_UNAVAILABLE'; + | 'LEVEL_NOT_FOUND' + | 'CAPABILITY_UNAVAILABLE' + | 'INVALID_INPUT'; export type CanContinueReason = 'NO_PREVIOUS_LIST' | 'INCOMPATIBLE_DEFINITIONS' | 'ALREADY_CONTINUOUS'; @@ -182,6 +213,116 @@ export interface ListsConvertToTextInput { includeMarker?: boolean; } +// --------------------------------------------------------------------------- +// SD-1973 template and formatting types +// --------------------------------------------------------------------------- + +/** A captured snapshot of one level's formatting properties. */ +export interface ListLevelTemplate { + level: number; + numFmt?: string; + lvlText?: string; + start?: number; + alignment?: LevelAlignment; + indents?: { + left?: number; + hanging?: number; + firstLine?: number; + }; + trailingCharacter?: TrailingCharacter; + markerFont?: string; + pictureBulletId?: number; +} + +/** A full list template: an array of level snapshots. */ +export interface ListTemplate { + version: 1; + levels: ListLevelTemplate[]; +} + +// --------------------------------------------------------------------------- +// Input types — SD-1973 formatting operations +// --------------------------------------------------------------------------- + +export interface ListsApplyTemplateInput { + target: ListItemAddress; + template: ListTemplate; + levels?: number[]; +} + +export interface ListsApplyPresetInput { + target: ListItemAddress; + preset: ListPresetId; + levels?: number[]; +} + +export interface ListsCaptureTemplateInput { + target: ListItemAddress; + levels?: number[]; +} + +export interface ListsSetLevelNumberingInput { + target: ListItemAddress; + level: number; + numFmt: string; + lvlText: string; + start?: number; +} + +export interface ListsSetLevelBulletInput { + target: ListItemAddress; + level: number; + markerText: string; +} + +export interface ListsSetLevelPictureBulletInput { + target: ListItemAddress; + level: number; + pictureBulletId: number; +} + +export interface ListsSetLevelAlignmentInput { + target: ListItemAddress; + level: number; + alignment: LevelAlignment; +} + +export interface ListsSetLevelIndentsInput { + target: ListItemAddress; + level: number; + left?: number; + hanging?: number; + firstLine?: number; +} + +export interface ListsSetLevelTrailingCharacterInput { + target: ListItemAddress; + level: number; + trailingCharacter: TrailingCharacter; +} + +export interface ListsSetLevelMarkerFontInput { + target: ListItemAddress; + level: number; + fontFamily: string; +} + +export interface ListsClearLevelOverridesInput { + target: ListItemAddress; + level: number; +} + +// --------------------------------------------------------------------------- +// Result types — SD-1973 +// --------------------------------------------------------------------------- + +export interface ListsCaptureTemplateSuccessResult { + success: true; + template: ListTemplate; +} + +export type ListsCaptureTemplateResult = ListsCaptureTemplateSuccessResult | ListsFailureResult; + // --------------------------------------------------------------------------- // Result types // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index 871b89b489..f0076da172 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -177,6 +177,11 @@ function makeCreateAdapter() { } function makeListsAdapter() { + const mutateResult = () => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + }); + return { list: vi.fn(() => ({ evaluatedRevision: 'r1', @@ -195,6 +200,7 @@ function makeListsAdapter() { })), get: vi.fn(() => ({ address: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + listId: 'list-1', kind: 'ordered' as const, level: 0, text: 'List item', @@ -204,26 +210,44 @@ function makeListsAdapter() { item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-2' }, insertionPoint: { kind: 'text' as const, blockId: 'li-2', range: { start: 0, end: 0 } }, })), - setType: vi.fn(() => ({ - success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-2' }, - })), - indent: vi.fn(() => ({ + indent: vi.fn(mutateResult), + outdent: vi.fn(mutateResult), + create: vi.fn(() => ({ success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-2' }, + listId: 'list-new', + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-new' }, })), - outdent: vi.fn(() => ({ + attach: vi.fn(mutateResult), + detach: vi.fn(() => ({ success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }, })), - restart: vi.fn(() => ({ + join: vi.fn(() => ({ success: true as const, listId: 'list-1' })), + canJoin: vi.fn(() => ({ canJoin: true })), + separate: vi.fn(() => ({ success: true as const, listId: 'list-new', numId: 2 })), + setLevel: vi.fn(mutateResult), + setValue: vi.fn(mutateResult), + continuePrevious: vi.fn(mutateResult), + canContinuePrevious: vi.fn(() => ({ canContinue: true })), + setLevelRestart: vi.fn(mutateResult), + convertToText: vi.fn(() => ({ success: true as const, - item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }, })), - exit: vi.fn(() => ({ + applyTemplate: vi.fn(mutateResult), + applyPreset: vi.fn(mutateResult), + captureTemplate: vi.fn(() => ({ success: true as const, - paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }, + template: { version: 1, levels: [] }, })), + setLevelNumbering: vi.fn(mutateResult), + setLevelBullet: vi.fn(mutateResult), + setLevelPictureBullet: vi.fn(mutateResult), + setLevelAlignment: vi.fn(mutateResult), + setLevelIndents: vi.fn(mutateResult), + setLevelTrailingCharacter: vi.fn(mutateResult), + setLevelMarkerFont: vi.fn(mutateResult), + clearLevelOverrides: vi.fn(mutateResult), }; } diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index 27f2708234..97b9ae5f5b 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -32,7 +32,9 @@ export type ReceiptFailureCode = | 'NO_PREVIOUS_LIST' | 'NO_ADJACENT_SEQUENCE' | 'ALREADY_SAME_SEQUENCE' - | 'LEVEL_OUT_OF_RANGE'; + | 'LEVEL_OUT_OF_RANGE' + // SD-1973 formatting failure codes + | 'LEVEL_NOT_FOUND'; export type ReceiptFailure = { code: ReceiptFailureCode; diff --git a/packages/sdk/codegen/src/generate-node.mjs b/packages/sdk/codegen/src/generate-node.mjs index e555af98d0..a8ec57daca 100644 --- a/packages/sdk/codegen/src/generate-node.mjs +++ b/packages/sdk/codegen/src/generate-node.mjs @@ -30,9 +30,42 @@ function generateContractTs(contract) { '/* eslint-disable */', '// Auto-generated by packages/sdk/codegen/src/generate-node.mjs', '', - `export const CONTRACT = ${JSON.stringify(contractForEmbed, null, 2)} as const;`, + 'export interface ContractParamEntry {', + ' name: string;', + " kind: 'doc' | 'flag' | 'jsonFlag';", + " type: 'string' | 'number' | 'boolean' | 'json' | 'string[]';", + ' flag?: string;', + ' required?: boolean;', + ' [key: string]: unknown;', + '}', + '', + 'export interface ContractOperationEntry {', + ' operationId: string;', + ' command: string;', + ' commandTokens: string[];', + ' params: ContractParamEntry[];', + ' constraints?: unknown;', + ' [key: string]: unknown;', + '}', + '', + 'export interface Contract {', + ' contractVersion: string;', + ' $defs?: Record;', + ' cli: {', + ' package: string;', + ' minVersion: string;', + ' };', + ' protocol: {', + ' version: string;', + ' transport: string;', + ' features: string[];', + ' notifications: string[];', + ' };', + ' operations: Record;', + '}', + '', + `export const CONTRACT: Contract = ${JSON.stringify(contractForEmbed, null, 2)};`, '', - 'export type Contract = typeof CONTRACT;', `export type OperationEntry = Contract['operations'][keyof Contract['operations']];`, '', ].join('\n'); 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 new file mode 100644 index 0000000000..6b712d0106 --- /dev/null +++ b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.js @@ -0,0 +1,765 @@ +// @ts-check +/** + * Per-level formatting mutators and template capture/apply logic for list definitions. + * + * This module handles abstract-definition-scope mutations only. + * Every mutator follows the three-step pattern: + * 1. Mutate raw XML in `editor.converter.numbering.abstracts[abstractNumId]` + * 2. Re-encode via `wAbstractNumTranslator.encode()` into `editor.converter.translatedNumbering` + * 3. Emit `list-definitions-change` for all numIds sharing that abstract + * + * Instance-scope overrides (w:lvlOverride) are handled by `list-numbering-helpers.js`. + */ +import { translator as wAbstractNumTranslator } from '@converter/v3/handlers/w/abstractNum'; +import { removeLvlOverride } from './list-numbering-helpers.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Constants +// ────────────────────────────────────────────────────────────────────────────── + +/** Standard per-level left indent increment in twips (720 twips = 0.5 inch). */ +const INDENT_PER_LEVEL_TWIPS = 720; + +/** Standard hanging indent in twips (360 twips = 0.25 inch). */ +const HANGING_INDENT_TWIPS = 360; + +// ────────────────────────────────────────────────────────────────────────────── +// Raw XML Utilities +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Find the `w:lvl` element for a given level index within an abstract definition. + * @param {Object} abstract - The raw `w:abstractNum` XML node. + * @param {number} ilvl - Level index (0–8). + * @returns {Object | undefined} The `w:lvl` element, or undefined if not found. + */ +function findLevelElement(abstract, ilvl) { + const ilvlStr = String(ilvl); + return abstract.elements?.find((el) => el.name === 'w:lvl' && el.attributes?.['w:ilvl'] === ilvlStr); +} + +/** + * Read the `w:val` attribute of a named child element. + * @param {Object} parent - Parent XML element. + * @param {string} elementName - Child element name (e.g. `w:numFmt`). + * @returns {string | undefined} The attribute value, or undefined if missing. + */ +function readChildAttr(parent, elementName) { + return parent.elements?.find((el) => el.name === elementName)?.attributes?.['w:val']; +} + +/** + * Set the `w:val` attribute on a named child element. Creates the element if missing. + * @param {Object} parent - Parent XML element. + * @param {string} elementName - Child element name (e.g. `w:numFmt`). + * @param {string} value - The value to set. + * @returns {boolean} True if the value changed, false if it was already equal. + */ +function setChildAttr(parent, elementName, value) { + if (!parent.elements) parent.elements = []; + const existing = parent.elements.find((el) => el.name === elementName); + + if (existing) { + if (existing.attributes?.['w:val'] === value) return false; + if (!existing.attributes) existing.attributes = {}; + existing.attributes['w:val'] = value; + return true; + } + + parent.elements.push({ + type: 'element', + name: elementName, + attributes: { 'w:val': value }, + }); + return true; +} + +/** + * Find or create a container child element (e.g. `w:pPr`, `w:rPr`). + * @param {Object} parent - Parent XML element. + * @param {string} elementName - Child element name. + * @returns {Object} The existing or newly created child element. + */ +function findOrCreateChild(parent, elementName) { + if (!parent.elements) parent.elements = []; + let child = parent.elements.find((el) => el.name === elementName); + if (!child) { + child = { type: 'element', name: elementName, elements: [] }; + parent.elements.push(child); + } + if (!child.elements) child.elements = []; + return child; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Sync Infrastructure +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Three-step abstract definition sync: + * 1. Persist the raw XML back to `editor.converter.numbering` + * 2. Re-encode into `editor.converter.translatedNumbering` + * 3. Emit `list-definitions-change` for every numId referencing this abstract + * + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {Object} abstract - The modified raw `w:abstractNum` node. + */ +function syncAbstractAndEmit(editor, abstractNumId, abstract) { + const numbering = editor.converter.numbering; + numbering.abstracts[abstractNumId] = abstract; + editor.converter.numbering = { ...numbering }; + + const translated = { ...(editor.converter.translatedNumbering || {}) }; + if (!translated.abstracts) translated.abstracts = {}; + // @ts-expect-error Remaining parameters are not needed for this translator + translated.abstracts[abstractNumId] = wAbstractNumTranslator.encode({ nodes: [abstract] }); + editor.converter.translatedNumbering = translated; + + const definitions = numbering.definitions || {}; + for (const [, numDef] of Object.entries(definitions)) { + const absId = numDef?.elements?.find((el) => el.name === 'w:abstractNumId')?.attributes?.['w:val']; + if (absId != null && Number(absId) === abstractNumId) { + editor.emit('list-definitions-change', { + change: { numDef, editor }, + numbering: editor.converter.numbering, + editor, + }); + } + } +} + +/** + * Resolve the abstract definition and level element from an editor. + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @returns {{ abstract: Object, lvlEl: Object } | null} + */ +function resolveAbstractLevel(editor, abstractNumId, ilvl) { + const abstract = editor.converter.numbering?.abstracts?.[abstractNumId]; + if (!abstract) return null; + const lvlEl = findLevelElement(abstract, ilvl); + if (!lvlEl) return null; + return { abstract, lvlEl }; +} + +/** + * Check whether a level element exists in an abstract definition. + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @returns {boolean} + */ +function hasLevel(editor, abstractNumId, ilvl) { + return resolveAbstractLevel(editor, abstractNumId, ilvl) != null; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Read Helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Read all formatting properties from a raw `w:lvl` element. + * Returns a normalized object matching the `ListLevelTemplate` shape. + * + * @param {Object} lvlEl - A raw `w:lvl` XML element. + * @param {number} ilvl - The level index (for the returned object). + * @returns {{ level: number, numFmt?: string, lvlText?: string, start?: number, alignment?: string, indents?: { left?: number, hanging?: number, firstLine?: number }, trailingCharacter?: string, markerFont?: string, pictureBulletId?: number }} + */ +function readLevelProperties(lvlEl, ilvl) { + /** @type {{ level: number, numFmt?: string, lvlText?: string, start?: number, alignment?: string, indents?: { left?: number, hanging?: number, firstLine?: number }, trailingCharacter?: string, markerFont?: string, pictureBulletId?: number }} */ + const props = { level: ilvl }; + + const numFmt = readChildAttr(lvlEl, 'w:numFmt'); + if (numFmt != null) props.numFmt = numFmt; + + const lvlText = readChildAttr(lvlEl, 'w:lvlText'); + if (lvlText != null) props.lvlText = lvlText; + + const startVal = readChildAttr(lvlEl, 'w:start'); + if (startVal != null) props.start = Number(startVal); + + const alignment = readChildAttr(lvlEl, 'w:lvlJc'); + if (alignment != null) props.alignment = alignment; + + const suff = readChildAttr(lvlEl, 'w:suff'); + if (suff != null) props.trailingCharacter = suff; + + const picBulletId = readChildAttr(lvlEl, 'w:lvlPicBulletId'); + if (picBulletId != null) props.pictureBulletId = Number(picBulletId); + + // Nested: indents from w:pPr > w:ind + const pPr = lvlEl.elements?.find((el) => el.name === 'w:pPr'); + const ind = pPr?.elements?.find((el) => el.name === 'w:ind'); + if (ind?.attributes) { + /** @type {Record} */ + const indents = {}; + if (ind.attributes['w:left'] != null) indents.left = Number(ind.attributes['w:left']); + if (ind.attributes['w:hanging'] != null) indents.hanging = Number(ind.attributes['w:hanging']); + if (ind.attributes['w:firstLine'] != null) indents.firstLine = Number(ind.attributes['w:firstLine']); + if (Object.keys(indents).length > 0) props.indents = indents; + } + + // Nested: marker font from w:rPr > w:rFonts (report ascii slot as canonical) + 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']) { + props.markerFont = rFonts.attributes['w:ascii']; + } + + return props; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Raw XML Mutators (no sync, no emit) +// +// Each function mutates a single w:lvl element in place. +// Returns true if any property was changed, false for no-op. +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Set numbering format, level text, and optionally start value. + * @param {Object} lvlEl + * @param {{ numFmt: string, lvlText: string, start?: number }} params + * @returns {boolean} + */ +function mutateLevelNumberingFormat(lvlEl, { numFmt, lvlText, start }) { + let changed = false; + changed = setChildAttr(lvlEl, 'w:numFmt', numFmt) || changed; + changed = setChildAttr(lvlEl, 'w:lvlText', lvlText) || changed; + if (start != null) { + changed = setChildAttr(lvlEl, 'w:start', String(start)) || changed; + } + return changed; +} + +/** + * Set bullet marker text. Sets `numFmt` to `'bullet'` and `lvlText` to the marker. + * Does NOT touch the marker font — that is a separate operation. + * @param {Object} lvlEl + * @param {string} markerText - The bullet character (e.g. '•'). + * @returns {boolean} + */ +function mutateLevelBulletMarker(lvlEl, markerText) { + let changed = false; + changed = setChildAttr(lvlEl, 'w:numFmt', 'bullet') || changed; + changed = setChildAttr(lvlEl, 'w:lvlText', markerText) || changed; + return changed; +} + +/** + * Set picture bullet ID. + * @param {Object} lvlEl + * @param {number} pictureBulletId + * @returns {boolean} + */ +function mutateLevelPictureBulletId(lvlEl, pictureBulletId) { + return setChildAttr(lvlEl, 'w:lvlPicBulletId', String(pictureBulletId)); +} + +/** + * Set level justification (alignment). + * @param {Object} lvlEl + * @param {string} alignment - `'left'` | `'center'` | `'right'`. + * @returns {boolean} + */ +function mutateLevelAlignment(lvlEl, alignment) { + return setChildAttr(lvlEl, 'w:lvlJc', alignment); +} + +/** + * Set level indentation. Only writes provided values; omitted values are left unchanged. + * `hanging` and `firstLine` are mutually exclusive — the caller must validate. + * When `hanging` is set, any existing `firstLine` is removed (and vice versa). + * @param {Object} lvlEl + * @param {{ left?: number, hanging?: number, firstLine?: number }} indents + * @returns {boolean} + */ +function mutateLevelIndents(lvlEl, indents) { + const pPr = findOrCreateChild(lvlEl, 'w:pPr'); + const ind = findOrCreateChild(pPr, 'w:ind'); + if (!ind.attributes) ind.attributes = {}; + + let changed = false; + + if (indents.left != null) { + const newVal = String(indents.left); + if (ind.attributes['w:left'] !== newVal) { + ind.attributes['w:left'] = newVal; + changed = true; + } + } + + if (indents.hanging != null) { + const newVal = String(indents.hanging); + if (ind.attributes['w:hanging'] !== newVal) { + ind.attributes['w:hanging'] = newVal; + changed = true; + } + if (ind.attributes['w:firstLine'] != null) { + delete ind.attributes['w:firstLine']; + changed = true; + } + } + + if (indents.firstLine != null) { + const newVal = String(indents.firstLine); + if (ind.attributes['w:firstLine'] !== newVal) { + ind.attributes['w:firstLine'] = newVal; + changed = true; + } + if (ind.attributes['w:hanging'] != null) { + delete ind.attributes['w:hanging']; + changed = true; + } + } + + return changed; +} + +/** + * Set trailing character (suffix). + * @param {Object} lvlEl + * @param {string} trailingCharacter - `'tab'` | `'space'` | `'nothing'`. + * @returns {boolean} + */ +function mutateLevelTrailingCharacter(lvlEl, trailingCharacter) { + return setChildAttr(lvlEl, 'w:suff', trailingCharacter); +} + +/** + * Set marker font family (`w:rPr > w:rFonts`). + * Sets all four font slots (ascii, hAnsi, eastAsia, cs) to the same family. + * @param {Object} lvlEl + * @param {string} fontFamily - The font family name. + * @returns {boolean} + */ +function mutateLevelMarkerFont(lvlEl, fontFamily) { + const rPr = findOrCreateChild(lvlEl, 'w:rPr'); + const rFonts = rPr.elements.find((el) => el.name === 'w:rFonts'); + + if (rFonts) { + const attrs = rFonts.attributes || {}; + if ( + attrs['w:ascii'] === fontFamily && + attrs['w:hAnsi'] === fontFamily && + attrs['w:eastAsia'] === fontFamily && + attrs['w:cs'] === fontFamily + ) { + return false; + } + rFonts.attributes = { + ...rFonts.attributes, + 'w:ascii': fontFamily, + 'w:hAnsi': fontFamily, + 'w:eastAsia': fontFamily, + 'w:cs': fontFamily, + }; + return true; + } + + rPr.elements.push({ + type: 'element', + name: 'w:rFonts', + attributes: { + 'w:ascii': fontFamily, + 'w:hAnsi': fontFamily, + 'w:eastAsia': fontFamily, + 'w:cs': fontFamily, + }, + }); + return true; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Composite Setters (mutate + sync + emit) +// +// Each function resolves the abstract + level, calls a raw mutator, and syncs +// only when a change was made. Returns true if changed, false for no-op. +// ────────────────────────────────────────────────────────────────────────────── + +/** + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {{ numFmt: string, lvlText: string, start?: number }} params + * @returns {boolean} + */ +function setLevelNumberingFormat(editor, abstractNumId, ilvl, params) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + const changed = mutateLevelNumberingFormat(resolved.lvlEl, params); + if (changed) syncAbstractAndEmit(editor, abstractNumId, resolved.abstract); + return changed; +} + +/** + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {string} markerText + * @returns {boolean} + */ +function setLevelBulletMarker(editor, abstractNumId, ilvl, markerText) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + const changed = mutateLevelBulletMarker(resolved.lvlEl, markerText); + if (changed) syncAbstractAndEmit(editor, abstractNumId, resolved.abstract); + return changed; +} + +/** + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {number} pictureBulletId + * @returns {boolean} + */ +function setLevelPictureBulletId(editor, abstractNumId, ilvl, pictureBulletId) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + const changed = mutateLevelPictureBulletId(resolved.lvlEl, pictureBulletId); + if (changed) syncAbstractAndEmit(editor, abstractNumId, resolved.abstract); + return changed; +} + +/** + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {string} alignment + * @returns {boolean} + */ +function setLevelAlignment(editor, abstractNumId, ilvl, alignment) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + const changed = mutateLevelAlignment(resolved.lvlEl, alignment); + if (changed) syncAbstractAndEmit(editor, abstractNumId, resolved.abstract); + return changed; +} + +/** + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {{ left?: number, hanging?: number, firstLine?: number }} indents + * @returns {boolean} + */ +function setLevelIndents(editor, abstractNumId, ilvl, indents) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + const changed = mutateLevelIndents(resolved.lvlEl, indents); + if (changed) syncAbstractAndEmit(editor, abstractNumId, resolved.abstract); + return changed; +} + +/** + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {string} trailingCharacter + * @returns {boolean} + */ +function setLevelTrailingCharacter(editor, abstractNumId, ilvl, trailingCharacter) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + const changed = mutateLevelTrailingCharacter(resolved.lvlEl, trailingCharacter); + if (changed) syncAbstractAndEmit(editor, abstractNumId, resolved.abstract); + return changed; +} + +/** + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl + * @param {string} fontFamily + * @returns {boolean} + */ +function setLevelMarkerFont(editor, abstractNumId, ilvl, fontFamily) { + const resolved = resolveAbstractLevel(editor, abstractNumId, ilvl); + if (!resolved) return false; + const changed = mutateLevelMarkerFont(resolved.lvlEl, fontFamily); + if (changed) syncAbstractAndEmit(editor, abstractNumId, resolved.abstract); + return changed; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Override Clearing +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Check whether a `w:lvlOverride` exists for the given numId and level. + * @param {import('../Editor').Editor} editor + * @param {number} numId + * @param {number} ilvl + * @returns {boolean} + */ +function hasLevelOverride(editor, numId, ilvl) { + const numDef = editor.converter.numbering?.definitions?.[numId]; + if (!numDef?.elements) return false; + const ilvlStr = String(ilvl); + return numDef.elements.some((el) => el.name === 'w:lvlOverride' && el.attributes?.['w:ilvl'] === ilvlStr); +} + +/** + * Remove a level override from a `w:num` definition. + * Returns true if an override was removed, false if none existed (no-op). + * + * @param {import('../Editor').Editor} editor + * @param {number} numId + * @param {number} ilvl + * @returns {boolean} + */ +function clearLevelOverride(editor, numId, ilvl) { + if (!hasLevelOverride(editor, numId, ilvl)) return false; + removeLvlOverride(editor, numId, ilvl); + return true; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Template Capture +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Capture a list template from an abstract definition. + * Returns a `ListTemplate`-shaped object with `version: 1`. + * + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number[] | undefined} levels - Specific levels to capture, or undefined for all. + * @returns {{ 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 }> } | null} + * Null if the abstract definition is missing. + */ +function captureTemplate(editor, abstractNumId, levels) { + const abstract = editor.converter.numbering?.abstracts?.[abstractNumId]; + if (!abstract?.elements) return null; + + 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; + captured.push(readLevelProperties(lvlEl, ilvl)); + } + + captured.sort((a, b) => a.level - b.level); + return { version: 1, levels: captured }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Template Application +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Apply a template to an abstract definition. Atomic: validates all requested + * levels exist before writing any. Returns whether any changes were made. + * + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {{ version: number, 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 }> }} template + * @param {number[] | undefined} levels - Subset of levels to apply, or undefined for all template levels. + * @returns {{ changed: boolean, error?: string }} + */ +function applyTemplateToAbstract(editor, abstractNumId, template, levels) { + const abstract = editor.converter.numbering?.abstracts?.[abstractNumId]; + if (!abstract?.elements) return { changed: false, error: 'ABSTRACT_NOT_FOUND' }; + + // Build a lookup of template levels by index + const templateByLevel = new Map(); + for (const entry of template.levels) { + templateByLevel.set(entry.level, entry); + } + + // Determine which levels to apply + const targetLevels = levels ?? template.levels.map((l) => l.level); + + // Validate: every requested level must exist in the template + for (const ilvl of targetLevels) { + if (!templateByLevel.has(ilvl)) { + return { changed: false, error: 'LEVEL_NOT_IN_TEMPLATE' }; + } + } + + // Validate: every requested level must exist in the abstract definition + for (const ilvl of targetLevels) { + if (!findLevelElement(abstract, ilvl)) { + return { changed: false, error: 'LEVEL_NOT_IN_ABSTRACT' }; + } + } + + // Mutate all requested levels (no sync until all are done) + let anyChanged = false; + 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; + + // Only call if at least numFmt or lvlText is present + if (fmtParams.numFmt != null && fmtParams.lvlText != null) { + anyChanged = mutateLevelNumberingFormat(lvlEl, fmtParams) || anyChanged; + } else { + // Partial: set individual fields + 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; + } + } + + if (anyChanged) { + syncAbstractAndEmit(editor, abstractNumId, abstract); + } + + return { changed: anyChanged }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Preset Catalog +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Configuration for ordered numbering presets. + * @type {Record} + */ +const ORDERED_PRESET_CONFIG = { + decimal: { numFmt: 'decimal', lvlTextSuffix: '.' }, + decimalParenthesis: { numFmt: 'decimal', lvlTextSuffix: ')' }, + lowerLetter: { numFmt: 'lowerLetter', lvlTextSuffix: '.' }, + upperLetter: { numFmt: 'upperLetter', lvlTextSuffix: '.' }, + lowerRoman: { numFmt: 'lowerRoman', lvlTextSuffix: '.' }, + upperRoman: { numFmt: 'upperRoman', lvlTextSuffix: '.' }, +}; + +/** + * Configuration for bullet presets. + * @type {Record} + */ +const BULLET_PRESET_CONFIG = { + disc: { markerText: '\u2022', fontFamily: 'Symbol' }, + circle: { markerText: 'o', fontFamily: 'Courier New' }, + square: { markerText: '\uF0A7', fontFamily: 'Wingdings' }, + dash: { markerText: '\u2013', fontFamily: 'Calibri' }, +}; + +/** + * Build a 9-level template for an ordered numbering preset. + * @param {{ numFmt: string, lvlTextSuffix: string }} config + * @returns {{ version: 1, levels: Array }} + */ +function buildOrderedPresetTemplate(config) { + const levels = []; + for (let ilvl = 0; ilvl <= 8; ilvl++) { + levels.push({ + level: ilvl, + numFmt: config.numFmt, + lvlText: `%${ilvl + 1}${config.lvlTextSuffix}`, + start: 1, + alignment: 'left', + indents: { + left: INDENT_PER_LEVEL_TWIPS * (ilvl + 1), + hanging: HANGING_INDENT_TWIPS, + }, + }); + } + return { version: 1, levels }; +} + +/** + * Build a 9-level template for a bullet preset. + * @param {{ markerText: string, fontFamily: string }} config + * @returns {{ version: 1, levels: Array }} + */ +function buildBulletPresetTemplate(config) { + const levels = []; + for (let ilvl = 0; ilvl <= 8; ilvl++) { + levels.push({ + level: ilvl, + numFmt: 'bullet', + lvlText: config.markerText, + start: 1, + alignment: 'left', + markerFont: config.fontFamily, + indents: { + left: INDENT_PER_LEVEL_TWIPS * (ilvl + 1), + hanging: HANGING_INDENT_TWIPS, + }, + }); + } + return { version: 1, levels }; +} + +/** @type {Record }>} */ +const PRESET_TEMPLATES = {}; + +for (const [id, config] of Object.entries(ORDERED_PRESET_CONFIG)) { + PRESET_TEMPLATES[id] = buildOrderedPresetTemplate(config); +} +for (const [id, config] of Object.entries(BULLET_PRESET_CONFIG)) { + PRESET_TEMPLATES[id] = buildBulletPresetTemplate(config); +} + +/** + * Get the full `ListTemplate` for a preset ID. + * @param {string} presetId - One of the `ListPresetId` values. + * @returns {{ version: 1, levels: Array } | undefined} + */ +function getPresetTemplate(presetId) { + return PRESET_TEMPLATES[presetId]; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Exports +// ────────────────────────────────────────────────────────────────────────────── + +export const LevelFormattingHelpers = { + // Read + readLevelProperties, + findLevelElement, + hasLevel, + + // Single-level composite setters + setLevelNumberingFormat, + setLevelBulletMarker, + setLevelPictureBulletId, + setLevelAlignment, + setLevelIndents, + setLevelTrailingCharacter, + setLevelMarkerFont, + + // Override clearing + hasLevelOverride, + clearLevelOverride, + + // Template operations + captureTemplate, + applyTemplateToAbstract, + + // Preset catalog + getPresetTemplate, + PRESET_TEMPLATES, +}; 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 new file mode 100644 index 0000000000..244beddb97 --- /dev/null +++ b/packages/super-editor/src/core/helpers/list-level-formatting-helpers.test.js @@ -0,0 +1,646 @@ +// @ts-check +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { LevelFormattingHelpers } from './list-level-formatting-helpers.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Test Fixtures +// ────────────────────────────────────────────────────────────────────────────── + +/** Create a minimal raw w:lvl element with standard defaults. */ +function makeLvlElement(ilvl, overrides = {}) { + const elements = [ + { type: 'element', name: 'w:start', attributes: { 'w:val': '1' } }, + { type: 'element', name: 'w:numFmt', attributes: { 'w:val': overrides.numFmt ?? 'decimal' } }, + { type: 'element', name: 'w:lvlText', attributes: { 'w:val': overrides.lvlText ?? `%${ilvl + 1}.` } }, + { type: 'element', name: 'w:lvlJc', attributes: { 'w:val': overrides.alignment ?? 'left' } }, + { + type: 'element', + name: 'w:pPr', + elements: [ + { + type: 'element', + name: 'w:ind', + attributes: { + 'w:left': String(overrides.left ?? 720 * (ilvl + 1)), + 'w:hanging': String(overrides.hanging ?? 360), + }, + }, + ], + }, + ]; + + if (overrides.suff) { + elements.push({ type: 'element', name: 'w:suff', attributes: { 'w:val': overrides.suff } }); + } + + if (overrides.fontFamily) { + elements.push({ + type: 'element', + name: 'w:rPr', + elements: [ + { + type: 'element', + name: 'w:rFonts', + attributes: { + 'w:ascii': overrides.fontFamily, + 'w:hAnsi': overrides.fontFamily, + 'w:eastAsia': overrides.fontFamily, + 'w:cs': overrides.fontFamily, + }, + }, + ], + }); + } + + return { + type: 'element', + name: 'w:lvl', + attributes: { 'w:ilvl': String(ilvl) }, + elements, + }; +} + +/** Create a minimal abstract definition with the given levels. */ +function makeAbstract(abstractNumId, levelCount = 9) { + const elements = []; + for (let i = 0; i < levelCount; i++) { + elements.push(makeLvlElement(i)); + } + return { + type: 'element', + name: 'w:abstractNum', + attributes: { 'w:abstractNumId': String(abstractNumId) }, + elements, + }; +} + +/** Create a mock editor with numbering data. */ +function makeEditor(abstractNumId = 1, numId = 10) { + const abstract = makeAbstract(abstractNumId); + const numDef = { + type: 'element', + name: 'w:num', + attributes: { 'w:numId': String(numId) }, + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': String(abstractNumId) } }], + }; + + return { + converter: { + numbering: { + abstracts: { [abstractNumId]: abstract }, + definitions: { [numId]: numDef }, + }, + translatedNumbering: { abstracts: {}, definitions: {} }, + }, + emit: vi.fn(), + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// findLevelElement +// ────────────────────────────────────────────────────────────────────────────── + +describe('findLevelElement', () => { + it('finds a level element by ilvl', () => { + const abstract = makeAbstract(1); + const lvl = LevelFormattingHelpers.findLevelElement(abstract, 0); + expect(lvl).toBeDefined(); + expect(lvl.attributes['w:ilvl']).toBe('0'); + }); + + it('returns undefined for missing level', () => { + const abstract = makeAbstract(1, 3); + expect(LevelFormattingHelpers.findLevelElement(abstract, 5)).toBeUndefined(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// readLevelProperties +// ────────────────────────────────────────────────────────────────────────────── + +describe('readLevelProperties', () => { + it('reads all standard properties from a level element', () => { + const lvl = makeLvlElement(2, { numFmt: 'lowerLetter', lvlText: '%3.', alignment: 'center', suff: 'space' }); + const props = LevelFormattingHelpers.readLevelProperties(lvl, 2); + + expect(props.level).toBe(2); + expect(props.numFmt).toBe('lowerLetter'); + expect(props.lvlText).toBe('%3.'); + expect(props.start).toBe(1); + expect(props.alignment).toBe('center'); + expect(props.trailingCharacter).toBe('space'); + expect(props.indents).toEqual({ left: 2160, hanging: 360 }); + }); + + it('reads marker font when present', () => { + const lvl = makeLvlElement(0, { fontFamily: 'Symbol' }); + const props = LevelFormattingHelpers.readLevelProperties(lvl, 0); + expect(props.markerFont).toBe('Symbol'); + }); + + it('omits missing optional properties', () => { + const lvl = makeLvlElement(0); + const props = LevelFormattingHelpers.readLevelProperties(lvl, 0); + expect(props.trailingCharacter).toBeUndefined(); + expect(props.markerFont).toBeUndefined(); + expect(props.pictureBulletId).toBeUndefined(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// setLevelNumberingFormat +// ────────────────────────────────────────────────────────────────────────────── + +describe('setLevelNumberingFormat', () => { + it('sets numFmt, lvlText, and start on the target level', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelNumberingFormat(editor, 1, 0, { + numFmt: 'lowerRoman', + lvlText: '%1)', + start: 5, + }); + + expect(changed).toBe(true); + expect(editor.emit).toHaveBeenCalled(); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl.elements.find((e) => e.name === 'w:numFmt').attributes['w:val']).toBe('lowerRoman'); + expect(lvl.elements.find((e) => e.name === 'w:lvlText').attributes['w:val']).toBe('%1)'); + expect(lvl.elements.find((e) => e.name === 'w:start').attributes['w:val']).toBe('5'); + }); + + it('returns false when values already match (no-op)', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelNumberingFormat(editor, 1, 0, { + numFmt: 'decimal', + lvlText: '%1.', + start: 1, + }); + + expect(changed).toBe(false); + expect(editor.emit).not.toHaveBeenCalled(); + }); + + it('omits start when not provided', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelNumberingFormat(editor, 1, 0, { + numFmt: 'upperLetter', + lvlText: '%1.', + }); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + // Start should remain unchanged at '1' + expect(lvl.elements.find((e) => e.name === 'w:start').attributes['w:val']).toBe('1'); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// setLevelBulletMarker +// ────────────────────────────────────────────────────────────────────────────── + +describe('setLevelBulletMarker', () => { + it('sets numFmt to bullet and lvlText to the marker', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelBulletMarker(editor, 1, 0, '\u2022'); + + expect(changed).toBe(true); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl.elements.find((e) => e.name === 'w:numFmt').attributes['w:val']).toBe('bullet'); + expect(lvl.elements.find((e) => e.name === 'w:lvlText').attributes['w:val']).toBe('\u2022'); + }); + + it('returns false when already a matching bullet', () => { + const editor = makeEditor(); + // First set to bullet + LevelFormattingHelpers.setLevelBulletMarker(editor, 1, 0, '\u2022'); + editor.emit.mockClear(); + + // Set again with same values + const changed = LevelFormattingHelpers.setLevelBulletMarker(editor, 1, 0, '\u2022'); + expect(changed).toBe(false); + expect(editor.emit).not.toHaveBeenCalled(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// setLevelPictureBulletId +// ────────────────────────────────────────────────────────────────────────────── + +describe('setLevelPictureBulletId', () => { + it('sets the picture bullet ID on a level', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelPictureBulletId(editor, 1, 0, 42); + + expect(changed).toBe(true); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl.elements.find((e) => e.name === 'w:lvlPicBulletId').attributes['w:val']).toBe('42'); + }); + + it('returns false when the same ID is already set', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelPictureBulletId(editor, 1, 0, 42); + editor.emit.mockClear(); + + const changed = LevelFormattingHelpers.setLevelPictureBulletId(editor, 1, 0, 42); + expect(changed).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// setLevelAlignment +// ────────────────────────────────────────────────────────────────────────────── + +describe('setLevelAlignment', () => { + it('sets the level justification', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelAlignment(editor, 1, 0, 'center'); + + expect(changed).toBe(true); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl.elements.find((e) => e.name === 'w:lvlJc').attributes['w:val']).toBe('center'); + }); + + it('returns false when alignment already matches', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelAlignment(editor, 1, 0, 'left'); + expect(changed).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// setLevelIndents +// ────────────────────────────────────────────────────────────────────────────── + +describe('setLevelIndents', () => { + it('sets left and hanging indents', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelIndents(editor, 1, 0, { left: 1440, hanging: 720 }); + + expect(changed).toBe(true); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + const ind = lvl.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('720'); + }); + + it('removes firstLine when hanging is set', () => { + const editor = makeEditor(); + // Manually add firstLine + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + const ind = lvl.elements.find((e) => e.name === 'w:pPr').elements.find((e) => e.name === 'w:ind'); + ind.attributes['w:firstLine'] = '200'; + + LevelFormattingHelpers.setLevelIndents(editor, 1, 0, { hanging: 500 }); + + expect(ind.attributes['w:firstLine']).toBeUndefined(); + expect(ind.attributes['w:hanging']).toBe('500'); + }); + + it('removes hanging when firstLine is set', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelIndents(editor, 1, 0, { firstLine: 300 }); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + const ind = lvl.elements.find((e) => e.name === 'w:pPr').elements.find((e) => e.name === 'w:ind'); + expect(ind.attributes['w:hanging']).toBeUndefined(); + expect(ind.attributes['w:firstLine']).toBe('300'); + }); + + it('returns false when indents already match', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelIndents(editor, 1, 0, { left: 720, hanging: 360 }); + expect(changed).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// setLevelTrailingCharacter +// ────────────────────────────────────────────────────────────────────────────── + +describe('setLevelTrailingCharacter', () => { + it('sets the trailing character (suffix)', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelTrailingCharacter(editor, 1, 0, 'space'); + + expect(changed).toBe(true); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl.elements.find((e) => e.name === 'w:suff').attributes['w:val']).toBe('space'); + }); + + it('returns false when suffix already matches', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelTrailingCharacter(editor, 1, 0, 'space'); + editor.emit.mockClear(); + + const changed = LevelFormattingHelpers.setLevelTrailingCharacter(editor, 1, 0, 'space'); + expect(changed).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// setLevelMarkerFont +// ────────────────────────────────────────────────────────────────────────────── + +describe('setLevelMarkerFont', () => { + it('creates rPr and rFonts when missing', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.setLevelMarkerFont(editor, 1, 0, 'Symbol'); + + expect(changed).toBe(true); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + const rPr = lvl.elements.find((e) => e.name === 'w:rPr'); + const rFonts = rPr.elements.find((e) => e.name === 'w:rFonts'); + expect(rFonts.attributes['w:ascii']).toBe('Symbol'); + expect(rFonts.attributes['w:hAnsi']).toBe('Symbol'); + expect(rFonts.attributes['w:eastAsia']).toBe('Symbol'); + expect(rFonts.attributes['w:cs']).toBe('Symbol'); + }); + + it('updates existing rFonts', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelMarkerFont(editor, 1, 0, 'Symbol'); + editor.emit.mockClear(); + + const changed = LevelFormattingHelpers.setLevelMarkerFont(editor, 1, 0, 'Wingdings'); + expect(changed).toBe(true); + + const lvl = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + const rFonts = lvl.elements.find((e) => e.name === 'w:rPr').elements.find((e) => e.name === 'w:rFonts'); + expect(rFonts.attributes['w:ascii']).toBe('Wingdings'); + }); + + it('returns false when font already matches', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelMarkerFont(editor, 1, 0, 'Symbol'); + editor.emit.mockClear(); + + const changed = LevelFormattingHelpers.setLevelMarkerFont(editor, 1, 0, 'Symbol'); + expect(changed).toBe(false); + expect(editor.emit).not.toHaveBeenCalled(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// clearLevelOverride +// ────────────────────────────────────────────────────────────────────────────── + +describe('clearLevelOverride', () => { + it('returns false when no override exists (no-op)', () => { + const editor = makeEditor(); + const changed = LevelFormattingHelpers.clearLevelOverride(editor, 10, 0); + expect(changed).toBe(false); + }); + + it('returns true and removes an existing override', () => { + const editor = makeEditor(); + // Add a lvlOverride to the numDef + 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': '5' } }], + }); + + const changed = LevelFormattingHelpers.clearLevelOverride(editor, 10, 0); + expect(changed).toBe(true); + expect(LevelFormattingHelpers.hasLevelOverride(editor, 10, 0)).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// captureTemplate +// ────────────────────────────────────────────────────────────────────────────── + +describe('captureTemplate', () => { + it('captures all levels from an abstract definition', () => { + const editor = makeEditor(); + const template = LevelFormattingHelpers.captureTemplate(editor, 1); + + expect(template).not.toBeNull(); + expect(template.version).toBe(1); + expect(template.levels).toHaveLength(9); + expect(template.levels[0].level).toBe(0); + expect(template.levels[0].numFmt).toBe('decimal'); + expect(template.levels[0].lvlText).toBe('%1.'); + expect(template.levels[0].start).toBe(1); + expect(template.levels[0].alignment).toBe('left'); + expect(template.levels[0].indents).toEqual({ left: 720, hanging: 360 }); + }); + + it('captures only specified levels', () => { + const editor = makeEditor(); + const template = LevelFormattingHelpers.captureTemplate(editor, 1, [0, 2, 4]); + + expect(template.levels).toHaveLength(3); + expect(template.levels.map((l) => l.level)).toEqual([0, 2, 4]); + }); + + it('returns null for missing abstract', () => { + const editor = makeEditor(); + expect(LevelFormattingHelpers.captureTemplate(editor, 999)).toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// applyTemplateToAbstract +// ────────────────────────────────────────────────────────────────────────────── + +describe('applyTemplateToAbstract', () => { + it('applies all template levels to the abstract', () => { + const editor = makeEditor(); + const template = { + version: 1, + levels: [ + { level: 0, numFmt: 'lowerRoman', lvlText: '%1)', start: 3, alignment: 'center' }, + { level: 1, numFmt: 'upperLetter', lvlText: '%2.', start: 1, alignment: 'right' }, + ], + }; + + const result = LevelFormattingHelpers.applyTemplateToAbstract(editor, 1, template); + + expect(result.changed).toBe(true); + expect(editor.emit).toHaveBeenCalled(); + + // Verify level 0 + const lvl0 = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl0.elements.find((e) => e.name === 'w:numFmt').attributes['w:val']).toBe('lowerRoman'); + expect(lvl0.elements.find((e) => e.name === 'w:lvlJc').attributes['w:val']).toBe('center'); + + // Verify level 1 + const lvl1 = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 1); + expect(lvl1.elements.find((e) => e.name === 'w:numFmt').attributes['w:val']).toBe('upperLetter'); + }); + + it('applies only specified levels from the template', () => { + const editor = makeEditor(); + const template = { + version: 1, + levels: [ + { level: 0, numFmt: 'lowerRoman', lvlText: '%1)' }, + { level: 1, numFmt: 'upperLetter', lvlText: '%2.' }, + ], + }; + + LevelFormattingHelpers.applyTemplateToAbstract(editor, 1, template, [0]); + + // Level 0 should be changed + const lvl0 = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl0.elements.find((e) => e.name === 'w:numFmt').attributes['w:val']).toBe('lowerRoman'); + + // Level 1 should be unchanged + const lvl1 = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 1); + expect(lvl1.elements.find((e) => e.name === 'w:numFmt').attributes['w:val']).toBe('decimal'); + }); + + it('returns error when requested level is not in template', () => { + const editor = makeEditor(); + const template = { + version: 1, + levels: [{ level: 0, numFmt: 'decimal', lvlText: '%1.' }], + }; + + const result = LevelFormattingHelpers.applyTemplateToAbstract(editor, 1, template, [0, 5]); + + expect(result.changed).toBe(false); + expect(result.error).toBe('LEVEL_NOT_IN_TEMPLATE'); + expect(editor.emit).not.toHaveBeenCalled(); + }); + + it('returns no-op when all values already match', () => { + const editor = makeEditor(); + const template = { + version: 1, + levels: [{ level: 0, numFmt: 'decimal', lvlText: '%1.', start: 1, alignment: 'left' }], + }; + + const result = LevelFormattingHelpers.applyTemplateToAbstract(editor, 1, template); + + expect(result.changed).toBe(false); + expect(editor.emit).not.toHaveBeenCalled(); + }); + + it('is atomic: no changes when any level is missing from abstract', () => { + const editor = makeEditor(1); + // Remove level 5 from abstract + const abstract = editor.converter.numbering.abstracts[1]; + abstract.elements = abstract.elements.filter((el) => !(el.name === 'w:lvl' && el.attributes?.['w:ilvl'] === '5')); + + const template = { + version: 1, + levels: [ + { level: 0, numFmt: 'lowerRoman', lvlText: '%1)' }, + { level: 5, numFmt: 'upperLetter', lvlText: '%6.' }, + ], + }; + + const result = LevelFormattingHelpers.applyTemplateToAbstract(editor, 1, template); + + expect(result.changed).toBe(false); + expect(result.error).toBe('LEVEL_NOT_IN_ABSTRACT'); + + // Level 0 should NOT have been modified (atomic rollback) + const lvl0 = LevelFormattingHelpers.findLevelElement(editor.converter.numbering.abstracts[1], 0); + expect(lvl0.elements.find((e) => e.name === 'w:numFmt').attributes['w:val']).toBe('decimal'); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Preset catalog +// ────────────────────────────────────────────────────────────────────────────── + +describe('preset catalog', () => { + it('provides templates for all 10 preset IDs', () => { + const presetIds = [ + 'decimal', + 'decimalParenthesis', + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'disc', + 'circle', + 'square', + 'dash', + ]; + + for (const id of presetIds) { + const template = LevelFormattingHelpers.getPresetTemplate(id); + expect(template, `preset ${id} should exist`).toBeDefined(); + expect(template.version).toBe(1); + expect(template.levels).toHaveLength(9); + } + }); + + it('returns undefined for unknown preset ID', () => { + expect(LevelFormattingHelpers.getPresetTemplate('nonexistent')).toBeUndefined(); + }); + + it('generates correct lvlText for ordered presets', () => { + const decimal = LevelFormattingHelpers.getPresetTemplate('decimal'); + expect(decimal.levels[0].lvlText).toBe('%1.'); + expect(decimal.levels[1].lvlText).toBe('%2.'); + expect(decimal.levels[8].lvlText).toBe('%9.'); + + const paren = LevelFormattingHelpers.getPresetTemplate('decimalParenthesis'); + expect(paren.levels[0].lvlText).toBe('%1)'); + }); + + it('generates correct indents per level', () => { + const decimal = LevelFormattingHelpers.getPresetTemplate('decimal'); + expect(decimal.levels[0].indents).toEqual({ left: 720, hanging: 360 }); + expect(decimal.levels[1].indents).toEqual({ left: 1440, hanging: 360 }); + expect(decimal.levels[8].indents).toEqual({ left: 6480, hanging: 360 }); + }); + + it('includes markerFont for bullet presets', () => { + const disc = LevelFormattingHelpers.getPresetTemplate('disc'); + expect(disc.levels[0].markerFont).toBe('Symbol'); + expect(disc.levels[0].numFmt).toBe('bullet'); + expect(disc.levels[0].lvlText).toBe('\u2022'); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Sync behavior +// ────────────────────────────────────────────────────────────────────────────── + +describe('sync and emit', () => { + it('emits list-definitions-change for all numIds sharing the abstract', () => { + const editor = makeEditor(1, 10); + // Add a second numId pointing to the same abstract + editor.converter.numbering.definitions[20] = { + type: 'element', + name: 'w:num', + attributes: { 'w:numId': '20' }, + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '1' } }], + }; + + LevelFormattingHelpers.setLevelAlignment(editor, 1, 0, 'right'); + + // Should emit for both numId 10 and numId 20 + expect(editor.emit).toHaveBeenCalledTimes(2); + expect(editor.emit).toHaveBeenCalledWith('list-definitions-change', expect.any(Object)); + }); + + it('does not emit when no change is made', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelAlignment(editor, 1, 0, 'left'); // already 'left' + expect(editor.emit).not.toHaveBeenCalled(); + }); + + it('updates translatedNumbering on change', () => { + const editor = makeEditor(); + LevelFormattingHelpers.setLevelAlignment(editor, 1, 0, 'center'); + + // translatedNumbering should have been updated (we can't fully verify the + // encoded shape without the real translator, but the key should exist) + expect(editor.converter.translatedNumbering.abstracts[1]).toBeDefined(); + }); +}); 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 b2b39e63a2..564e21797c 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 @@ -123,7 +123,21 @@ import { listsSetLevelRestartWrapper, listsConvertToTextWrapper, } from '../plan-engine/lists-wrappers.js'; +import { + listsApplyTemplateWrapper, + listsApplyPresetWrapper, + listsCaptureTemplateWrapper, + listsSetLevelNumberingWrapper, + listsSetLevelBulletWrapper, + listsSetLevelPictureBulletWrapper, + listsSetLevelAlignmentWrapper, + listsSetLevelIndentsWrapper, + listsSetLevelTrailingCharacterWrapper, + listsSetLevelMarkerFontWrapper, + listsClearLevelOverridesWrapper, +} 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'; import * as planWrappers from '../plan-engine/plan-wrappers.js'; import { trackChangesAcceptWrapper, trackChangesRejectWrapper } from '../plan-engine/track-changes-wrappers.js'; import { registerBuiltInExecutors } from '../plan-engine/register-executors.js'; @@ -3121,6 +3135,345 @@ const mutationVectors: Partial> = { }); }, }, + // SD-1973 formatting operations + 'lists.applyTemplate': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsApplyTemplateWrapper( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + template: { version: 1, levels: [] }, + }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsApplyTemplateWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + template: { version: 99 as any, levels: [] }, + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const applySpy = vi + .spyOn(LevelFormattingHelpers, 'applyTemplateToAbstract') + .mockReturnValue({ changed: true, levelsApplied: [0] }); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsApplyTemplateWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + template: { version: 1, levels: [{ level: 0, numFmt: 'upperRoman', lvlText: '%1.' }] }, + }); + abstractSpy.mockRestore(); + applySpy.mockRestore(); + return result; + }, + }, + 'lists.applyPreset': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsApplyPresetWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, preset: 'decimal' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsApplyPresetWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + preset: 'nonexistent' as any, + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const applySpy = vi + .spyOn(LevelFormattingHelpers, 'applyTemplateToAbstract') + .mockReturnValue({ changed: true, levelsApplied: [0] }); + const presetSpy = vi + .spyOn(LevelFormattingHelpers, 'getPresetTemplate') + .mockReturnValue({ version: 1, levels: [{ level: 0, numFmt: 'decimal', lvlText: '%1.' }] }); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsApplyPresetWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + preset: 'decimal', + }); + abstractSpy.mockRestore(); + applySpy.mockRestore(); + presetSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelNumbering': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelNumberingWrapper( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + numFmt: 'upperRoman', + lvlText: '%1.', + }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelNumberingWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + numFmt: 'upperRoman', + lvlText: '%1.', + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelNumberingFormat').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelNumberingWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + numFmt: 'upperRoman', + lvlText: '%1.', + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelBullet': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelBulletWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, markerText: '•' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelBulletWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + markerText: '•', + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelBulletMarker').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelBulletWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + markerText: '•', + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelPictureBullet': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelPictureBulletWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, pictureBulletId: 1 }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelPictureBulletWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + pictureBulletId: 1, + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelPictureBulletId').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelPictureBulletWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + pictureBulletId: 1, + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelAlignment': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelAlignmentWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, alignment: 'center' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelAlignmentWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + alignment: 'center', + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelAlignment').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelAlignmentWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + alignment: 'center', + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelIndents': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelIndentsWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, left: 720 }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelIndentsWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + hanging: 360, + firstLine: 360, + } as any); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelIndents').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelIndentsWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + left: 720, + hanging: 360, + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelTrailingCharacter': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelTrailingCharacterWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, trailingCharacter: 'space' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelTrailingCharacterWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + trailingCharacter: 'space', + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelTrailingCharacter').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelTrailingCharacterWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + trailingCharacter: 'space', + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelMarkerFont': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelMarkerFontWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, fontFamily: 'Arial' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelMarkerFontWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + fontFamily: 'Arial', + }); + }, + applyCase: () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const hasLevelSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevel').mockReturnValue(true); + const setSpy = vi.spyOn(LevelFormattingHelpers, 'setLevelMarkerFont').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelMarkerFontWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + fontFamily: 'Arial', + }); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + setSpy.mockRestore(); + return result; + }, + }, + 'lists.clearLevelOverrides': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsClearLevelOverridesWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0 }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsClearLevelOverridesWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + }); + }, + applyCase: () => { + const hasSpy = vi.spyOn(LevelFormattingHelpers, 'hasLevelOverride').mockReturnValue(true); + const clearSpy = vi.spyOn(LevelFormattingHelpers, 'clearLevelOverride').mockImplementation(() => {}); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsClearLevelOverridesWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + }); + hasSpy.mockRestore(); + clearSpy.mockRestore(); + return result; + }, + }, 'comments.create': { throwCase: () => { const editor = makeCommentsEditor([], { addComment: undefined }); @@ -4917,6 +5270,143 @@ const dryRunVectors: Partial unknown>> = { return result; }, + // ------------------------------------------------------------------------- + // SD-1973 list formatting — dryRun vectors + // ------------------------------------------------------------------------- + 'lists.applyTemplate': () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsApplyTemplateWrapper( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + template: { version: 1, levels: [{ level: 0, numFmt: 'decimal', lvlText: '%1.' }] }, + }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + return result; + }, + 'lists.applyPreset': () => { + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const presetSpy = vi + .spyOn(LevelFormattingHelpers, 'getPresetTemplate') + .mockReturnValue({ version: 1, levels: [{ level: 0, numFmt: 'decimal', lvlText: '%1.' }] }); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsApplyPresetWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, preset: 'decimal' }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + presetSpy.mockRestore(); + return result; + }, + 'lists.setLevelNumbering': () => { + 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 = listsSetLevelNumberingWrapper( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + numFmt: 'upperRoman', + lvlText: '%1.', + }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelBullet': () => { + 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 = listsSetLevelBulletWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, markerText: '•' }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelPictureBullet': () => { + 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 = listsSetLevelPictureBulletWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, pictureBulletId: 1 }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelAlignment': () => { + 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 = listsSetLevelAlignmentWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, alignment: 'center' }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelIndents': () => { + 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 = listsSetLevelIndentsWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, left: 720 }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelTrailingCharacter': () => { + 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 = listsSetLevelTrailingCharacterWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, trailingCharacter: 'space' }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.setLevelMarkerFont': () => { + 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 = listsSetLevelMarkerFontWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, fontFamily: 'Arial' }, + { changeMode: 'direct', dryRun: true }, + ); + abstractSpy.mockRestore(); + hasLevelSpy.mockRestore(); + return result; + }, + 'lists.clearLevelOverrides': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsClearLevelOverridesWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0 }, + { changeMode: 'direct', dryRun: true }, + ); + }, + // ------------------------------------------------------------------------- // 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 c10ee40d1f..0ecabeae70 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -60,6 +60,19 @@ import { listsSetLevelRestartWrapper, listsConvertToTextWrapper, } from './plan-engine/lists-wrappers.js'; +import { + listsApplyTemplateWrapper, + listsApplyPresetWrapper, + listsCaptureTemplateWrapper, + listsSetLevelNumberingWrapper, + listsSetLevelBulletWrapper, + listsSetLevelPictureBulletWrapper, + listsSetLevelAlignmentWrapper, + listsSetLevelIndentsWrapper, + listsSetLevelTrailingCharacterWrapper, + listsSetLevelMarkerFontWrapper, + listsClearLevelOverridesWrapper, +} from './plan-engine/lists-formatting-wrappers.js'; import { executePlan } from './plan-engine/executor.js'; import { previewPlan } from './plan-engine/preview.js'; import { queryMatchAdapter } from './plan-engine/query-match-adapter.js'; @@ -269,6 +282,17 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters canContinuePrevious: (input) => listsCanContinuePreviousWrapper(editor, input), setLevelRestart: (input, options) => listsSetLevelRestartWrapper(editor, input, options), convertToText: (input, options) => listsConvertToTextWrapper(editor, input, options), + applyTemplate: (input, options) => listsApplyTemplateWrapper(editor, input, options), + applyPreset: (input, options) => listsApplyPresetWrapper(editor, input, options), + captureTemplate: (input) => listsCaptureTemplateWrapper(editor, input), + setLevelNumbering: (input, options) => listsSetLevelNumberingWrapper(editor, input, options), + setLevelBullet: (input, options) => listsSetLevelBulletWrapper(editor, input, options), + setLevelPictureBullet: (input, options) => listsSetLevelPictureBulletWrapper(editor, input, options), + setLevelAlignment: (input, options) => listsSetLevelAlignmentWrapper(editor, input, options), + setLevelIndents: (input, options) => listsSetLevelIndentsWrapper(editor, input, options), + setLevelTrailingCharacter: (input, options) => listsSetLevelTrailingCharacterWrapper(editor, input, options), + setLevelMarkerFont: (input, options) => listsSetLevelMarkerFontWrapper(editor, input, options), + clearLevelOverrides: (input, options) => listsClearLevelOverridesWrapper(editor, input, options), }, sections: { list: (query) => sectionsListAdapter(editor, query), diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index 43d5ebea88..244e92bca0 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -168,6 +168,65 @@ describe('getDocumentApiCapabilities', () => { } }); + // --------------------------------------------------------------------------- + // SD-1973 list formatting operations + // --------------------------------------------------------------------------- + + it('advertises dryRun for SD-1973 list formatting mutators', () => { + const capabilities = getDocumentApiCapabilities(makeEditor()); + // setLevelPictureBullet excluded — requires numbering XML helper (tested separately) + const formattingOps = [ + 'lists.applyTemplate', + 'lists.applyPreset', + 'lists.setLevelNumbering', + 'lists.setLevelBullet', + 'lists.setLevelAlignment', + 'lists.setLevelIndents', + 'lists.setLevelTrailingCharacter', + 'lists.setLevelMarkerFont', + 'lists.clearLevelOverrides', + ] as const; + + for (const operationId of formattingOps) { + expect(capabilities.operations[operationId].available, `${operationId} should be available`).toBe(true); + expect(capabilities.operations[operationId].dryRun, `${operationId} should advertise dryRun`).toBe(true); + } + }); + + it('marks lists.captureTemplate as available (read-only, no dryRun)', () => { + const capabilities = getDocumentApiCapabilities(makeEditor()); + expect(capabilities.operations['lists.captureTemplate'].available).toBe(true); + // captureTemplate is read-only — dryRun depends on catalog metadata + }); + + it('marks lists.setLevelPictureBullet as unavailable when numbering XML is missing', () => { + // Default editor has no converter → no numbering XML + const capabilities = getDocumentApiCapabilities(makeEditor()); + expect(capabilities.operations['lists.setLevelPictureBullet'].available).toBe(false); + expect(capabilities.operations['lists.setLevelPictureBullet'].reasons).toContain('HELPER_UNAVAILABLE'); + expect(capabilities.operations['lists.setLevelPictureBullet'].reasons).toContain('OPERATION_UNAVAILABLE'); + }); + + it('marks lists.setLevelPictureBullet as available when numbering XML is present', () => { + const editor = makeEditor(); + (editor as unknown as Record).converter = { + convertedXml: { 'word/numbering.xml': { name: 'root', elements: [] } }, + }; + + const capabilities = getDocumentApiCapabilities(editor); + expect(capabilities.operations['lists.setLevelPictureBullet'].available).toBe(true); + expect(capabilities.operations['lists.setLevelPictureBullet'].reasons).toBeUndefined(); + }); + + it('keeps global lists namespace enabled with all SD-1973 operations registered', () => { + const capabilities = getDocumentApiCapabilities(makeEditor()); + // lists.setLevelPictureBullet is unavailable (no converter) but the namespace + // check only looks at command availability — the helper predicate does not affect it. + // However, since setLevelPictureBullet has an empty command array, hasAllCommands + // returns true. The namespace check uses hasAllCommands, so it stays enabled. + expect(capabilities.global.lists.enabled).toBe(true); + }); + it('reports tracked mode unavailable when no editor user is configured', () => { const capabilities = getDocumentApiCapabilities( makeEditor({ diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 39f993a988..9e67f457eb 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -46,6 +46,18 @@ const REQUIRED_COMMANDS: Partial boolean> 'sections.setHeaderFooterRef': (editor) => Boolean((editor as unknown as { converter?: unknown }).converter), 'tables.setDefaultStyle': (editor) => Boolean((editor as unknown as { converter?: unknown }).converter), 'tables.clearDefaultStyle': (editor) => Boolean((editor as unknown as { converter?: unknown }).converter), + // Picture bullet requires the numbering part to support lvlPicBulletId references. + 'lists.setLevelPictureBullet': (editor) => { + const converter = (editor as unknown as { converter?: { convertedXml?: Record } }).converter; + return Boolean(converter?.convertedXml?.['word/numbering.xml']); + }, }; function hasRequiredHelpers(editor: Editor, operationId: OperationId): boolean { 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 new file mode 100644 index 0000000000..ea9c71034d --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-formatting-wrappers.ts @@ -0,0 +1,554 @@ +/** + * Lists formatting wrappers — bridge SD-1973 list formatting operations + * (template/preset/level formatting) to the plan engine. + * + * Structural list operations (insert, create, attach, detach, join, separate, etc.) + * remain in `lists-wrappers.ts`. This file handles only formatting operations. + * + * All level mutations are definition-scoped (no `scope` parameter in v1). + * `clearLevelOverrides` is the only instance-scope operation (removes w:lvlOverride). + */ + +import type { Editor } from '../../core/Editor.js'; +import type { + ListsApplyTemplateInput, + ListsApplyPresetInput, + ListsCaptureTemplateInput, + ListsCaptureTemplateResult, + ListsSetLevelNumberingInput, + ListsSetLevelBulletInput, + ListsSetLevelPictureBulletInput, + ListsSetLevelAlignmentInput, + ListsSetLevelIndentsInput, + ListsSetLevelTrailingCharacterInput, + ListsSetLevelMarkerFontInput, + ListsClearLevelOverridesInput, + ListsMutateItemResult, + ListTemplate, + MutationOptions, + ReceiptFailureCode, +} from '@superdoc/document-api'; +import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { executeDomainCommand } from './plan-wrappers.js'; +import { resolveListItem } from '../helpers/list-item-resolver.js'; +import { getAbstractNumId } from '../helpers/list-sequence-helpers.js'; +import { LevelFormattingHelpers } from '../../core/helpers/list-level-formatting-helpers.js'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function toListsFailure(code: ReceiptFailureCode, message: string, details?: unknown) { + return { success: false as const, failure: { code, message, details } }; +} + +function dispatchEditorTransaction(editor: Editor, tr: unknown): void { + if (typeof editor.dispatch === 'function') { + editor.dispatch(tr as Parameters[0]); + return; + } + if (typeof editor.view?.dispatch === 'function') { + editor.view.dispatch(tr as Parameters['dispatch']>[0]); + } +} + +/** + * Validate that a level index is within the valid range (0–8). + * Returns a failure result if invalid, or null if valid. + */ +function validateLevel(level: number): ListsMutateItemResult | null { + if (level < 0 || level > 8) { + return toListsFailure('LEVEL_OUT_OF_RANGE', 'Level must be between 0 and 8.', { level }); + } + return 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. + */ +function validateLevelsArray( + levels: number[] | undefined, +): { success: false; failure: { code: ReceiptFailureCode; message: string; details?: unknown } } | null { + if (!levels) return null; + + for (const lvl of levels) { + if (lvl < 0 || lvl > 8) { + return toListsFailure('LEVEL_OUT_OF_RANGE', 'Each level must be between 0 and 8.', { level: lvl }); + } + } + + 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; +} + +/** + * Preflight check for template/preset application — validates that every + * requested level exists in the template. This runs before dry-run returns + * success so that dry-run faithfully reflects whether real execution would + * succeed for template-side constraints. Abstract-side checks (level exists + * in the numbering definition) are deferred to `applyTemplateToAbstract`. + */ +function preflightTemplateLevels( + template: ListTemplate, + levels: number[] | undefined, + target: { kind: 'block'; nodeType: 'listItem'; nodeId: string }, +): ListsMutateItemResult | null { + const templateLevelSet = new Set(template.levels.map((l) => l.level)); + const targetLevels = levels ?? template.levels.map((l) => l.level); + + for (const ilvl of targetLevels) { + if (!templateLevelSet.has(ilvl)) { + return toListsFailure('INVALID_INPUT', 'Requested level does not exist in the template.', { target }); + } + } + + return null; +} + +/** + * Map `applyTemplateToAbstract` error strings to proper failure results. + */ +function toApplyTemplateError( + error: string, + target: { kind: 'block'; nodeType: 'listItem'; nodeId: string }, +): ListsMutateItemResult { + switch (error) { + case 'ABSTRACT_NOT_FOUND': + return toListsFailure('INVALID_TARGET', 'Abstract numbering definition not found.', { target }); + case 'LEVEL_NOT_IN_TEMPLATE': + return toListsFailure('INVALID_INPUT', 'Requested level does not exist in the template.', { target }); + case 'LEVEL_NOT_IN_ABSTRACT': + return toListsFailure('INVALID_TARGET', 'Requested level does not exist in the abstract definition.', { target }); + default: + return toListsFailure('INVALID_INPUT', `Template application failed: ${error}.`, { target }); + } +} + +type TargetAbstractSuccess = { + ok: true; + resolved: ReturnType; + abstractNumId: number; + numId: number; +}; +type TargetAbstractFailure = { ok: false; failure: ReturnType }; + +/** + * Resolve target list item and its abstract definition ID. + * Returns `{ ok: true, ... }` on success, `{ ok: false, failure }` on failure. + */ +function resolveTargetAbstract( + editor: Editor, + target: { kind: 'block'; nodeType: 'listItem'; nodeId: string }, +): TargetAbstractSuccess | TargetAbstractFailure { + const resolved = resolveListItem(editor, target); + if (resolved.numId == null) { + return { + ok: false, + failure: toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target }), + }; + } + const abstractNumId = getAbstractNumId(editor, resolved.numId); + if (abstractNumId == null) { + return { + ok: false, + failure: toListsFailure('INVALID_TARGET', 'Could not resolve abstract definition for target.', { target }), + }; + } + return { ok: true, resolved, abstractNumId, numId: resolved.numId }; +} + +// --------------------------------------------------------------------------- +// Single-level mutation helper (DRY pattern for all setLevel* operations) +// --------------------------------------------------------------------------- + +/** + * Execute a single-level mutation operation on an abstract definition. + * Handles: tracked mode rejection, target resolution, level validation, + * level existence check, dry-run short-circuit, no-op detection, and + * domain command execution. + */ +function executeSingleLevelMutation( + 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; + + // Verify the requested level actually exists in the abstract definition + 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 }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const changed = mutate(targetResult.abstractNumId, level); + if (!changed) return false; + dispatchEditorTransaction(editor, editor.state.tr); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('NO_OP', `${operationId}: values already match.`, { target }); + } + + return { success: true, item: targetResult.resolved.address }; +} + +// --------------------------------------------------------------------------- +// Exported wrappers +// --------------------------------------------------------------------------- + +export function listsApplyTemplateWrapper( + editor: Editor, + input: ListsApplyTemplateInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.applyTemplate', options); + + if (input.template.version !== 1) { + return toListsFailure('INVALID_INPUT', 'Unsupported template version.', { version: input.template.version }); + } + + const levelsError = validateLevelsArray(input.levels); + if (levelsError) return levelsError; + + const targetResult = resolveTargetAbstract(editor, input.target); + if (!targetResult.ok) return (targetResult as TargetAbstractFailure).failure; + + // Preflight: validate template levels before dry-run can succeed + const preflightError = preflightTemplateLevels(input.template, input.levels, input.target); + if (preflightError) return preflightError; + + if (options?.dryRun) { + return { success: true, item: targetResult.resolved.address }; + } + + let applyError: string | undefined; + + const receipt = executeDomainCommand( + editor, + () => { + const result = LevelFormattingHelpers.applyTemplateToAbstract( + editor, + targetResult.abstractNumId, + input.template, + input.levels, + ) as { changed: boolean; error?: string }; + if (result.error) { + applyError = result.error; + return false; + } + if (!result.changed) return false; + dispatchEditorTransaction(editor, editor.state.tr); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (applyError) { + return toApplyTemplateError(applyError, input.target); + } + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('NO_OP', 'All template levels already match.', { target: input.target }); + } + + return { success: true, item: targetResult.resolved.address }; +} + +export function listsApplyPresetWrapper( + editor: Editor, + input: ListsApplyPresetInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.applyPreset', options); + + const template = LevelFormattingHelpers.getPresetTemplate(input.preset) as ListTemplate | undefined; + if (!template) { + return toListsFailure('INVALID_INPUT', `Unknown preset: ${input.preset}.`, { preset: input.preset }); + } + + const levelsError = validateLevelsArray(input.levels); + if (levelsError) return levelsError; + + const targetResult = resolveTargetAbstract(editor, input.target); + if (!targetResult.ok) return (targetResult as TargetAbstractFailure).failure; + + // Preflight: validate template levels before dry-run can succeed + const preflightError = preflightTemplateLevels(template, input.levels, input.target); + if (preflightError) return preflightError; + + if (options?.dryRun) { + return { success: true, item: targetResult.resolved.address }; + } + + let applyError: string | undefined; + + const receipt = executeDomainCommand( + editor, + () => { + const result = LevelFormattingHelpers.applyTemplateToAbstract( + editor, + targetResult.abstractNumId, + template, + input.levels, + ) as { changed: boolean; error?: string }; + if (result.error) { + applyError = result.error; + return false; + } + if (!result.changed) return false; + dispatchEditorTransaction(editor, editor.state.tr); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (applyError) { + return toApplyTemplateError(applyError, input.target); + } + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('NO_OP', 'All preset levels already match.', { target: input.target }); + } + + return { success: true, item: targetResult.resolved.address }; +} + +export function listsCaptureTemplateWrapper( + editor: Editor, + input: ListsCaptureTemplateInput, +): ListsCaptureTemplateResult { + const levelsError = validateLevelsArray(input.levels); + if (levelsError) return levelsError; + + const targetResult = resolveTargetAbstract(editor, input.target); + if (!targetResult.ok) return (targetResult as TargetAbstractFailure).failure; + + const template = LevelFormattingHelpers.captureTemplate( + editor, + targetResult.abstractNumId, + input.levels, + ) as ListTemplate | null; + if (!template) { + return toListsFailure('INVALID_TARGET', 'Could not capture template from target.', { target: input.target }); + } + + return { success: true, template }; +} + +export function listsSetLevelNumberingWrapper( + editor: Editor, + input: ListsSetLevelNumberingInput, + options?: MutationOptions, +): ListsMutateItemResult { + return executeSingleLevelMutation( + editor, + 'lists.setLevelNumbering', + input.target, + input.level, + options, + (abstractNumId, ilvl) => + LevelFormattingHelpers.setLevelNumberingFormat(editor, abstractNumId, ilvl, { + numFmt: input.numFmt, + lvlText: input.lvlText, + start: input.start, + }), + ); +} + +export function listsSetLevelBulletWrapper( + editor: Editor, + input: ListsSetLevelBulletInput, + options?: MutationOptions, +): ListsMutateItemResult { + return executeSingleLevelMutation( + editor, + 'lists.setLevelBullet', + input.target, + input.level, + options, + (abstractNumId, ilvl) => LevelFormattingHelpers.setLevelBulletMarker(editor, abstractNumId, ilvl, input.markerText), + ); +} + +export function listsSetLevelPictureBulletWrapper( + editor: Editor, + input: ListsSetLevelPictureBulletInput, + options?: MutationOptions, +): ListsMutateItemResult { + // Tracked mode must reject before any domain checks (conformance contract) + rejectTrackedMode('lists.setLevelPictureBullet', options); + + // Guard: picture bullet requires numbering.xml, matching the capability predicate. + // Only reject when we can definitively determine the feature is unavailable + // (converter has convertedXml but it lacks numbering.xml). + const converter = (editor as unknown as { converter?: { convertedXml?: Record } }).converter; + if (converter?.convertedXml && !converter.convertedXml['word/numbering.xml']) { + return toListsFailure( + 'CAPABILITY_UNAVAILABLE', + 'Picture bullets require a numbering definition (word/numbering.xml).', + { + target: input.target, + }, + ); + } + + return executeSingleLevelMutation( + editor, + 'lists.setLevelPictureBullet', + input.target, + input.level, + options, + (abstractNumId, ilvl) => + LevelFormattingHelpers.setLevelPictureBulletId(editor, abstractNumId, ilvl, input.pictureBulletId), + ); +} + +export function listsSetLevelAlignmentWrapper( + editor: Editor, + input: ListsSetLevelAlignmentInput, + options?: MutationOptions, +): ListsMutateItemResult { + return executeSingleLevelMutation( + editor, + 'lists.setLevelAlignment', + input.target, + input.level, + options, + (abstractNumId, ilvl) => LevelFormattingHelpers.setLevelAlignment(editor, abstractNumId, ilvl, input.alignment), + ); +} + +export function listsSetLevelIndentsWrapper( + editor: Editor, + input: ListsSetLevelIndentsInput, + options?: MutationOptions, +): ListsMutateItemResult { + // Runtime validation: at least one indent field required, hanging + firstLine mutually exclusive + const hasLeft = input.left != null; + const hasHanging = input.hanging != null; + const hasFirstLine = input.firstLine != null; + + if (!hasLeft && !hasHanging && !hasFirstLine) { + return toListsFailure('INVALID_INPUT', 'At least one indent property is required.', {}); + } + if (hasHanging && hasFirstLine) { + return toListsFailure('INVALID_INPUT', 'hanging and firstLine are mutually exclusive.', {}); + } + + return executeSingleLevelMutation( + editor, + 'lists.setLevelIndents', + input.target, + input.level, + options, + (abstractNumId, ilvl) => { + const indents: { left?: number; hanging?: number; firstLine?: number } = {}; + if (hasLeft) indents.left = input.left; + if (hasHanging) indents.hanging = input.hanging; + if (hasFirstLine) indents.firstLine = input.firstLine; + return LevelFormattingHelpers.setLevelIndents(editor, abstractNumId, ilvl, indents); + }, + ); +} + +export function listsSetLevelTrailingCharacterWrapper( + editor: Editor, + input: ListsSetLevelTrailingCharacterInput, + options?: MutationOptions, +): ListsMutateItemResult { + return executeSingleLevelMutation( + editor, + 'lists.setLevelTrailingCharacter', + input.target, + input.level, + options, + (abstractNumId, ilvl) => + LevelFormattingHelpers.setLevelTrailingCharacter(editor, abstractNumId, ilvl, input.trailingCharacter), + ); +} + +export function listsSetLevelMarkerFontWrapper( + editor: Editor, + input: ListsSetLevelMarkerFontInput, + options?: MutationOptions, +): ListsMutateItemResult { + return executeSingleLevelMutation( + editor, + 'lists.setLevelMarkerFont', + input.target, + input.level, + options, + (abstractNumId, ilvl) => LevelFormattingHelpers.setLevelMarkerFont(editor, abstractNumId, ilvl, input.fontFamily), + ); +} + +export function listsClearLevelOverridesWrapper( + editor: Editor, + input: ListsClearLevelOverridesInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.clearLevelOverrides', options); + + const levelError = validateLevel(input.level); + if (levelError) return levelError; + + const resolved = resolveListItem(editor, input.target); + if (resolved.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); + } + + if (options?.dryRun) { + return { success: true, item: resolved.address }; + } + + // Check if override exists (no-op detection) + if (!LevelFormattingHelpers.hasLevelOverride(editor, resolved.numId, input.level)) { + return toListsFailure('NO_OP', 'No override exists for this level.', { target: input.target, level: input.level }); + } + + const receipt = executeDomainCommand( + editor, + () => { + LevelFormattingHelpers.clearLevelOverride(editor, resolved.numId!, input.level); + dispatchEditorTransaction(editor, editor.state.tr); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('NO_OP', 'clearLevelOverrides could not be applied.', { target: input.target }); + } + + return { success: true, item: resolved.address }; +} diff --git a/tests/doc-api-stories/tests/lists/all-commands.ts b/tests/doc-api-stories/tests/lists/all-commands.ts index d2a5614f23..a76f78d5fa 100644 --- a/tests/doc-api-stories/tests/lists/all-commands.ts +++ b/tests/doc-api-stories/tests/lists/all-commands.ts @@ -21,6 +21,18 @@ const ALL_LISTS_COMMAND_IDS = [ 'lists.canContinuePrevious', 'lists.setLevelRestart', 'lists.convertToText', + // SD-1973 formatting operations + 'lists.applyTemplate', + 'lists.applyPreset', + 'lists.captureTemplate', + 'lists.setLevelNumbering', + 'lists.setLevelBullet', + 'lists.setLevelPictureBullet', + 'lists.setLevelAlignment', + 'lists.setLevelIndents', + 'lists.setLevelTrailingCharacter', + 'lists.setLevelMarkerFont', + 'lists.clearLevelOverrides', ] as const; type ListsCommandId = (typeof ALL_LISTS_COMMAND_IDS)[number]; @@ -55,6 +67,7 @@ describe('document-api story: all lists commands', () => { 'lists.get', 'lists.canJoin', 'lists.canContinuePrevious', + 'lists.captureTemplate', ]); function slug(operationId: ListsCommandId): string { @@ -112,6 +125,13 @@ describe('document-api story: all lists commands', () => { return; } + if (operationId === 'lists.captureTemplate') { + expect(result?.success).toBe(true); + expect(result?.template?.version).toBe(1); + expect(Array.isArray(result?.template?.levels)).toBe(true); + return; + } + throw new Error(`Unexpected read assertion branch for ${operationId}.`); } @@ -445,6 +465,178 @@ describe('document-api story: all lists commands', () => { }); }, }, + // SD-1973 formatting operations + { + operationId: 'lists.captureTemplate', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, _resultDoc, fixture) => { + const f = requireFixture('lists.captureTemplate', fixture); + return callDocOperation('lists.captureTemplate', { + doc: sourceDoc, + target: f.firstItem, + }); + }, + }, + { + operationId: 'lists.applyPreset', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.applyPreset', fixture); + return callDocOperation('lists.applyPreset', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + preset: 'upperRoman', + }); + }, + }, + { + operationId: 'lists.applyTemplate', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.applyTemplate', fixture); + // Capture template and tweak level 0 so the apply is not a no-op + const captureResult = await callDocOperation('lists.captureTemplate', { + doc: sourceDoc, + target: f.firstItem, + }); + const template = captureResult?.template; + if (template?.levels?.[0]) { + template.levels[0].numFmt = 'upperRoman'; + template.levels[0].lvlText = '%1)'; + } + return callDocOperation('lists.applyTemplate', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + template, + }); + }, + }, + { + operationId: 'lists.setLevelNumbering', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelNumbering', fixture); + return callDocOperation('lists.setLevelNumbering', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + numFmt: 'upperRoman', + lvlText: '%1.', + }); + }, + }, + { + operationId: 'lists.setLevelBullet', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelBullet', fixture); + return callDocOperation('lists.setLevelBullet', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + markerText: '▪', + }); + }, + }, + { + operationId: 'lists.setLevelPictureBullet', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelPictureBullet', fixture); + return callDocOperation('lists.setLevelPictureBullet', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + pictureBulletId: 0, + }); + }, + }, + { + operationId: 'lists.setLevelAlignment', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelAlignment', fixture); + return callDocOperation('lists.setLevelAlignment', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + alignment: 'center', + }); + }, + }, + { + operationId: 'lists.setLevelIndents', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelIndents', fixture); + return callDocOperation('lists.setLevelIndents', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + left: 1440, + hanging: 360, + }); + }, + }, + { + operationId: 'lists.setLevelTrailingCharacter', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelTrailingCharacter', fixture); + return callDocOperation('lists.setLevelTrailingCharacter', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + trailingCharacter: 'space', + }); + }, + }, + { + operationId: 'lists.setLevelMarkerFont', + prepareSource: async (sourceDoc) => setupListFixture(sourceDoc), + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.setLevelMarkerFont', fixture); + return callDocOperation('lists.setLevelMarkerFont', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + fontFamily: 'Arial', + }); + }, + }, + { + operationId: 'lists.clearLevelOverrides', + prepareSource: async (sourceDoc) => { + const fixture = await setupListFixture(sourceDoc); + // Create a w:lvlOverride (start override) so there is something to clear + const overrideResult = await callDocOperation('lists.setValue', { + doc: sourceDoc, + out: sourceDoc, + target: fixture.firstItem, + value: 10, + }); + assertMutationSuccess('lists.setValue (prep)', overrideResult); + return fixture; + }, + run: async (sourceDoc, resultDoc, fixture) => { + const f = requireFixture('lists.clearLevelOverrides', fixture); + return callDocOperation('lists.clearLevelOverrides', { + doc: sourceDoc, + out: resultDoc, + target: f.firstItem, + level: 0, + }); + }, + }, ]; it('covers every lists command currently defined on this branch', () => {