From b14b899627829907a63da082330df418d24fe188 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 12:02:10 -0700 Subject: [PATCH 1/2] feat(document-api): add paragraph direction ops and clarify format.rtl --- .../document-api/available-operations.mdx | 4 +- .../reference/_generated-manifest.json | 8 +- .../reference/capabilities/get.mdx | 92 +++++ .../format/paragraph/clear-direction.mdx | 350 +++++++++++++++++ .../reference/format/paragraph/index.mdx | 2 + .../format/paragraph/set-direction.mdx | 369 ++++++++++++++++++ .../document-api/reference/format/rtl.mdx | 6 +- apps/docs/document-api/reference/index.mdx | 6 +- .../src/contract/operation-definitions.ts | 45 ++- .../src/contract/operation-registry.ts | 12 + packages/document-api/src/contract/schemas.ts | 19 + packages/document-api/src/index.ts | 16 + packages/document-api/src/invoke/invoke.ts | 2 + .../document-api/src/paragraphs/paragraphs.ts | 55 ++- .../src/paragraphs/paragraphs.types.ts | 22 ++ .../pm-adapter/src/attributes/paragraph.ts | 1 + .../pm-adapter/src/index.test.ts | 2 +- .../contract-conformance.test.ts | 50 +++ .../assemble-adapters.test.ts | 4 + .../assemble-adapters.ts | 4 + .../helpers/sd-projection.ts | 4 +- .../plan-engine/paragraphs-wrappers.ts | 47 +++ .../node-materializer.ts | 2 +- .../src/extensions/types/node-attributes.ts | 1 + 24 files changed, 1108 insertions(+), 15 deletions(-) create mode 100644 apps/docs/document-api/reference/format/paragraph/clear-direction.mdx create mode 100644 apps/docs/document-api/reference/format/paragraph/set-direction.mdx diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 85740aa0d9..c4fa46472e 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -35,7 +35,7 @@ Use the tables below to see what operations are available and where each one is | Index | 11 | 0 | 11 | [Reference](/document-api/reference/index/index) | | Lists | 36 | 0 | 36 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | -| Paragraph Formatting | 17 | 0 | 17 | [Reference](/document-api/reference/format/paragraph/index) | +| Paragraph Formatting | 19 | 0 | 19 | [Reference](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Reference](/document-api/reference/styles/paragraph/index) | | Query | 1 | 0 | 1 | [Reference](/document-api/reference/query/index) | | Ranges | 1 | 0 | 1 | [Reference](/document-api/reference/ranges/index) | @@ -332,6 +332,8 @@ Use the tables below to see what operations are available and where each one is | editor.doc.format.paragraph.clearBorder(...) | [`format.paragraph.clearBorder`](/document-api/reference/format/paragraph/clear-border) | | editor.doc.format.paragraph.setShading(...) | [`format.paragraph.setShading`](/document-api/reference/format/paragraph/set-shading) | | editor.doc.format.paragraph.clearShading(...) | [`format.paragraph.clearShading`](/document-api/reference/format/paragraph/clear-shading) | +| editor.doc.format.paragraph.setDirection(...) | [`format.paragraph.setDirection`](/document-api/reference/format/paragraph/set-direction) | +| editor.doc.format.paragraph.clearDirection(...) | [`format.paragraph.clearDirection`](/document-api/reference/format/paragraph/clear-direction) | | editor.doc.styles.paragraph.setStyle(...) | [`styles.paragraph.setStyle`](/document-api/reference/styles/paragraph/set-style) | | editor.doc.styles.paragraph.clearStyle(...) | [`styles.paragraph.clearStyle`](/document-api/reference/styles/paragraph/clear-style) | | editor.doc.query.match(...) | [`query.match`](/document-api/reference/query/match) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 8eb4076244..8c1f5fc4f6 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -177,6 +177,7 @@ "apps/docs/document-api/reference/format/paragraph/clear-alignment.mdx", "apps/docs/document-api/reference/format/paragraph/clear-all-tab-stops.mdx", "apps/docs/document-api/reference/format/paragraph/clear-border.mdx", + "apps/docs/document-api/reference/format/paragraph/clear-direction.mdx", "apps/docs/document-api/reference/format/paragraph/clear-indentation.mdx", "apps/docs/document-api/reference/format/paragraph/clear-shading.mdx", "apps/docs/document-api/reference/format/paragraph/clear-spacing.mdx", @@ -185,6 +186,7 @@ "apps/docs/document-api/reference/format/paragraph/reset-direct-formatting.mdx", "apps/docs/document-api/reference/format/paragraph/set-alignment.mdx", "apps/docs/document-api/reference/format/paragraph/set-border.mdx", + "apps/docs/document-api/reference/format/paragraph/set-direction.mdx", "apps/docs/document-api/reference/format/paragraph/set-flow-options.mdx", "apps/docs/document-api/reference/format/paragraph/set-indentation.mdx", "apps/docs/document-api/reference/format/paragraph/set-keep-options.mdx", @@ -637,7 +639,9 @@ "format.paragraph.setBorder", "format.paragraph.clearBorder", "format.paragraph.setShading", - "format.paragraph.clearShading" + "format.paragraph.clearShading", + "format.paragraph.setDirection", + "format.paragraph.clearDirection" ], "pagePath": "apps/docs/document-api/reference/format/paragraph/index.mdx", "title": "Paragraph Formatting" @@ -982,5 +986,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "31ad98dfc8659ebef07fbe3070add1c26fbbc8e0fbfa284e52d0b17e9fdd6b0f" + "sourceHash": "dae934164941335b329bcc852b86b9c692662b326cbeb23d5498983dc265317b" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index f363b8c77f..12a7e8665a 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -1077,6 +1077,11 @@ _No fields._ | `operations.format.paragraph.clearBorder.dryRun` | boolean | yes | | | `operations.format.paragraph.clearBorder.reasons` | enum[] | no | | | `operations.format.paragraph.clearBorder.tracked` | boolean | yes | | +| `operations.format.paragraph.clearDirection` | object | yes | | +| `operations.format.paragraph.clearDirection.available` | boolean | yes | | +| `operations.format.paragraph.clearDirection.dryRun` | boolean | yes | | +| `operations.format.paragraph.clearDirection.reasons` | enum[] | no | | +| `operations.format.paragraph.clearDirection.tracked` | boolean | yes | | | `operations.format.paragraph.clearIndentation` | object | yes | | | `operations.format.paragraph.clearIndentation.available` | boolean | yes | | | `operations.format.paragraph.clearIndentation.dryRun` | boolean | yes | | @@ -1112,6 +1117,11 @@ _No fields._ | `operations.format.paragraph.setBorder.dryRun` | boolean | yes | | | `operations.format.paragraph.setBorder.reasons` | enum[] | no | | | `operations.format.paragraph.setBorder.tracked` | boolean | yes | | +| `operations.format.paragraph.setDirection` | object | yes | | +| `operations.format.paragraph.setDirection.available` | boolean | yes | | +| `operations.format.paragraph.setDirection.dryRun` | boolean | yes | | +| `operations.format.paragraph.setDirection.reasons` | enum[] | no | | +| `operations.format.paragraph.setDirection.tracked` | boolean | yes | | | `operations.format.paragraph.setFlowOptions` | object | yes | | | `operations.format.paragraph.setFlowOptions.available` | boolean | yes | | | `operations.format.paragraph.setFlowOptions.dryRun` | boolean | yes | | @@ -3243,6 +3253,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "format.paragraph.clearDirection": { + "available": true, + "dryRun": true, + "tracked": false + }, "format.paragraph.clearIndentation": { "available": true, "dryRun": true, @@ -3278,6 +3293,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "format.paragraph.setDirection": { + "available": true, + "dryRun": true, + "tracked": false + }, "format.paragraph.setFlowOptions": { "available": true, "dryRun": true, @@ -11621,6 +11641,41 @@ _No fields._ ], "type": "object" }, + "format.paragraph.clearDirection": { + "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" + }, "format.paragraph.clearIndentation": { "additionalProperties": false, "properties": { @@ -11866,6 +11921,41 @@ _No fields._ ], "type": "object" }, + "format.paragraph.setDirection": { + "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" + }, "format.paragraph.setFlowOptions": { "additionalProperties": false, "properties": { @@ -19214,6 +19304,8 @@ _No fields._ "format.paragraph.clearBorder", "format.paragraph.setShading", "format.paragraph.clearShading", + "format.paragraph.setDirection", + "format.paragraph.clearDirection", "lists.list", "lists.get", "lists.insert", diff --git a/apps/docs/document-api/reference/format/paragraph/clear-direction.mdx b/apps/docs/document-api/reference/format/paragraph/clear-direction.mdx new file mode 100644 index 0000000000..6c26893208 --- /dev/null +++ b/apps/docs/document-api/reference/format/paragraph/clear-direction.mdx @@ -0,0 +1,350 @@ +--- +title: format.paragraph.clearDirection +sidebarTitle: format.paragraph.clearDirection +description: Remove explicit paragraph direction, reverting to inherited or default (LTR). +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Remove explicit paragraph direction, reverting to inherited or default (LTR). + +- Operation ID: `format.paragraph.clearDirection` +- API member path: `editor.doc.format.paragraph.clearDirection(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ParagraphMutationResult; reports NO_OP if no direction is set. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | + +### Example request + +```json +{ + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `resolution` | object | yes | | +| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `success` | `true` | yes | Constant: `true` | +| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `resolution` | object | no | | +| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "resolution": { + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } + }, + "success": true, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": true + }, + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "success", + "target", + "resolution" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": true + }, + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "success", + "target", + "resolution" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/format/paragraph/index.mdx b/apps/docs/document-api/reference/format/paragraph/index.mdx index f7caa498c2..3ed2ae4c18 100644 --- a/apps/docs/document-api/reference/format/paragraph/index.mdx +++ b/apps/docs/document-api/reference/format/paragraph/index.mdx @@ -31,4 +31,6 @@ Paragraph-level direct formatting: alignment, indentation, spacing, borders, sha | format.paragraph.clearBorder | `format.paragraph.clearBorder` | Yes | `conditional` | No | Yes | | format.paragraph.setShading | `format.paragraph.setShading` | Yes | `conditional` | No | Yes | | format.paragraph.clearShading | `format.paragraph.clearShading` | Yes | `conditional` | No | Yes | +| format.paragraph.setDirection | `format.paragraph.setDirection` | Yes | `conditional` | No | Yes | +| format.paragraph.clearDirection | `format.paragraph.clearDirection` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/format/paragraph/set-direction.mdx b/apps/docs/document-api/reference/format/paragraph/set-direction.mdx new file mode 100644 index 0000000000..6a5dd41a26 --- /dev/null +++ b/apps/docs/document-api/reference/format/paragraph/set-direction.mdx @@ -0,0 +1,369 @@ +--- +title: format.paragraph.setDirection +sidebarTitle: format.paragraph.setDirection +description: "Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match. + +- Operation ID: `format.paragraph.setDirection` +- API member path: `editor.doc.format.paragraph.setDirection(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ParagraphMutationResult; reports NO_OP if the direction already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `alignmentPolicy` | enum | no | `"preserve"`, `"matchDirection"` | +| `direction` | enum | yes | `"ltr"`, `"rtl"` | +| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | + +### Example request + +```json +{ + "alignmentPolicy": "preserve", + "direction": "ltr", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `resolution` | object | yes | | +| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `success` | `true` | yes | Constant: `true` | +| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `resolution` | object | no | | +| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "resolution": { + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } + }, + "success": true, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "alignmentPolicy": { + "enum": [ + "preserve", + "matchDirection" + ], + "type": "string" + }, + "direction": { + "enum": [ + "ltr", + "rtl" + ], + "type": "string" + }, + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target", + "direction" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": true + }, + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "success", + "target", + "resolution" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": true + }, + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "success", + "target", + "resolution" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphAddress" + }, + { + "$ref": "#/$defs/HeadingAddress" + }, + { + "$ref": "#/$defs/ListItemAddress" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/format/rtl.mdx b/apps/docs/document-api/reference/format/rtl.mdx index b7863791cd..dbad91f973 100644 --- a/apps/docs/document-api/reference/format/rtl.mdx +++ b/apps/docs/document-api/reference/format/rtl.mdx @@ -1,7 +1,7 @@ --- title: format.rtl sidebarTitle: format.rtl -description: "Set or clear the `rtl` inline run property on the target text range." +description: "Set or clear the `rtl` inline run property on the target text range. This does not change paragraph direction; use `format.paragraph.setDirection` for paragraph-level RTL." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: "Set or clear the `rtl` inline run property on the target text rang ## Summary -Set or clear the `rtl` inline run property on the target text range. +Set or clear the `rtl` inline run property on the target text range. This does not change paragraph direction; use `format.paragraph.setDirection` for paragraph-level RTL. - Operation ID: `format.rtl` - API member path: `editor.doc.format.rtl(...)` @@ -22,7 +22,7 @@ Set or clear the `rtl` inline run property on the target text range. ## Expected result -Returns a TextMutationReceipt confirming the inline run property patch was applied to the target range. +Returns a TextMutationReceipt confirming only the inline run property patch was applied to the target range; paragraph direction is unchanged. ## Input fields diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index c4cea64439..c7c323919d 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -32,7 +32,7 @@ Document API is currently alpha and subject to breaking changes. | Track Changes | 3 | 0 | 3 | [Open](/document-api/reference/track-changes/index) | | Query | 1 | 0 | 1 | [Open](/document-api/reference/query/index) | | Mutations | 2 | 0 | 2 | [Open](/document-api/reference/mutations/index) | -| Paragraph Formatting | 17 | 0 | 17 | [Open](/document-api/reference/format/paragraph/index) | +| Paragraph Formatting | 19 | 0 | 19 | [Open](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Open](/document-api/reference/styles/paragraph/index) | | Tables | 45 | 0 | 45 | [Open](/document-api/reference/tables/index) | | History | 3 | 0 | 3 | [Open](/document-api/reference/history/index) | @@ -152,7 +152,7 @@ The tables below are grouped by namespace. | format.vanish | editor.doc.format.vanish(...) | Set or clear the `vanish` inline run property on the target text range. | | format.webHidden | editor.doc.format.webHidden(...) | Set or clear the `webHidden` inline run property on the target text range. | | format.specVanish | editor.doc.format.specVanish(...) | Set or clear the `specVanish` inline run property on the target text range. | -| format.rtl | editor.doc.format.rtl(...) | Set or clear the `rtl` inline run property on the target text range. | +| format.rtl | editor.doc.format.rtl(...) | Set or clear the `rtl` inline run property on the target text range. This does not change paragraph direction; use `format.paragraph.setDirection` for paragraph-level RTL. | | format.cs | editor.doc.format.cs(...) | Set or clear the `cs` inline run property on the target text range. | | format.bCs | editor.doc.format.bCs(...) | Set or clear the `bCs` inline run property on the target text range. | | format.iCs | editor.doc.format.iCs(...) | Set or clear the `iCs` inline run property on the target text range. | @@ -271,6 +271,8 @@ The tables below are grouped by namespace. | format.paragraph.clearBorder | editor.doc.format.paragraph.clearBorder(...) | Remove border for a specific side or all sides of a paragraph. | | format.paragraph.setShading | editor.doc.format.paragraph.setShading(...) | Set paragraph shading (background fill, pattern color, pattern type). | | format.paragraph.clearShading | editor.doc.format.paragraph.clearShading(...) | Remove all paragraph shading. | +| format.paragraph.setDirection | editor.doc.format.paragraph.setDirection(...) | Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match. | +| format.paragraph.clearDirection | editor.doc.format.paragraph.clearDirection(...) | Remove explicit paragraph direction, reverting to inherited or default (LTR). | #### Paragraph Styles diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 5fef67cf9f..c61d4df72a 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -249,9 +249,19 @@ function camelToKebab(value: string): string { } function formatInlineAliasDescription(key: InlineRunPatchKey): string { + if (key === 'rtl') { + return 'Set or clear the `rtl` inline run property on the target text range. This does not change paragraph direction; use `format.paragraph.setDirection` for paragraph-level RTL.'; + } return `Set or clear the \`${key}\` inline run property on the target text range.`; } +function formatInlineAliasExpectedResult(key: InlineRunPatchKey): string { + if (key === 'rtl') { + return 'Returns a TextMutationReceipt confirming only the inline run property patch was applied to the target range; paragraph direction is unchanged.'; + } + return 'Returns a TextMutationReceipt confirming the inline run property patch was applied to the target range.'; +} + const FORMAT_INLINE_ALIAS_OPERATION_DEFINITIONS: Record = Object.fromEntries( INLINE_PROPERTY_REGISTRY.map((entry) => { @@ -259,8 +269,7 @@ const FORMAT_INLINE_ALIAS_OPERATION_DEFINITIONS: Record = { success: paragraphMutationSuccessSchema, failure: paragraphMutationFailureSchemaFor('format.paragraph.clearShading'), }, + 'format.paragraph.setDirection': { + input: objectSchema( + { + target: paragraphTargetSchema, + direction: { type: 'string', enum: ['ltr', 'rtl'] }, + alignmentPolicy: { type: 'string', enum: ['preserve', 'matchDirection'] }, + }, + ['target', 'direction'], + ), + output: paragraphMutationResultSchemaFor('format.paragraph.setDirection'), + success: paragraphMutationSuccessSchema, + failure: paragraphMutationFailureSchemaFor('format.paragraph.setDirection'), + }, + 'format.paragraph.clearDirection': { + input: objectSchema({ target: paragraphTargetSchema }, ['target']), + output: paragraphMutationResultSchemaFor('format.paragraph.clearDirection'), + success: paragraphMutationSuccessSchema, + failure: paragraphMutationFailureSchemaFor('format.paragraph.clearDirection'), + }, 'styles.apply': (() => { // Derived from PROPERTY_REGISTRY — no hardcoded property lists const runInputSchema = objectSchema( diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 84a97b1dc4..7ba8d898ba 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -346,6 +346,8 @@ import type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, + ParagraphsSetDirectionInput, + ParagraphsClearDirectionInput, ParagraphMutationResult, } from './paragraphs/paragraphs.js'; import { @@ -368,6 +370,8 @@ import { executeParagraphsClearBorder, executeParagraphsSetShading, executeParagraphsClearShading, + executeParagraphsSetDirection, + executeParagraphsClearDirection, } from './paragraphs/paragraphs.js'; import type { SectionsAdapter, SectionsApi } from './sections/sections.js'; import type { @@ -1172,6 +1176,10 @@ export type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, + ParagraphsSetDirectionInput, + ParagraphsClearDirectionInput, + ParagraphDirection, + AlignmentPolicy, } from './paragraphs/paragraphs.js'; export { PARAGRAPH_ALIGNMENTS, @@ -1180,6 +1188,8 @@ export { BORDER_SIDES, CLEAR_BORDER_SIDES, LINE_RULES, + PARAGRAPH_DIRECTIONS, + ALIGNMENT_POLICIES, } from './paragraphs/paragraphs.js'; export type { BlockAddress, @@ -1878,6 +1888,12 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult { return executeParagraphsClearShading(adapters.paragraphs, input, options); }, + setDirection(input: ParagraphsSetDirectionInput, options?: MutationOptions): ParagraphMutationResult { + return executeParagraphsSetDirection(adapters.paragraphs, input, options); + }, + clearDirection(input: ParagraphsClearDirectionInput, options?: MutationOptions): ParagraphMutationResult { + return executeParagraphsClearDirection(adapters.paragraphs, input, options); + }, }, }, styles: { diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 14082d8c0c..c687bf13d1 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -105,6 +105,8 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'format.paragraph.clearBorder': (input, options) => api.format.paragraph.clearBorder(input, options), 'format.paragraph.setShading': (input, options) => api.format.paragraph.setShading(input, options), 'format.paragraph.clearShading': (input, options) => api.format.paragraph.clearShading(input, options), + 'format.paragraph.setDirection': (input, options) => api.format.paragraph.setDirection(input, options), + 'format.paragraph.clearDirection': (input, options) => api.format.paragraph.clearDirection(input, options), // --- styles.* --- 'styles.apply': (input, options) => api.styles.apply(input, options), diff --git a/packages/document-api/src/paragraphs/paragraphs.ts b/packages/document-api/src/paragraphs/paragraphs.ts index 1c88f0a0c0..247f5086f1 100644 --- a/packages/document-api/src/paragraphs/paragraphs.ts +++ b/packages/document-api/src/paragraphs/paragraphs.ts @@ -2,7 +2,7 @@ * Engine-agnostic paragraphs domain module. * * Defines the adapter interface, validation, and execute* functions - * for all 19 `format.paragraph.*` / `styles.paragraph.*` operations. + * for all `format.paragraph.*` / `styles.paragraph.*` operations. */ import { normalizeMutationOptions, type MutationOptions } from '../write/write.js'; @@ -30,6 +30,8 @@ import type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, + ParagraphsSetDirectionInput, + ParagraphsClearDirectionInput, } from './paragraphs.types.js'; import { PARAGRAPH_ALIGNMENTS, @@ -38,6 +40,8 @@ import { BORDER_SIDES, CLEAR_BORDER_SIDES, LINE_RULES, + PARAGRAPH_DIRECTIONS, + ALIGNMENT_POLICIES, } from './paragraphs.types.js'; // Re-export types @@ -73,6 +77,10 @@ export type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, + ParagraphsSetDirectionInput, + ParagraphsClearDirectionInput, + ParagraphDirection, + AlignmentPolicy, } from './paragraphs.types.js'; export { PARAGRAPH_ALIGNMENTS, @@ -81,6 +89,8 @@ export { BORDER_SIDES, CLEAR_BORDER_SIDES, LINE_RULES, + PARAGRAPH_DIRECTIONS, + ALIGNMENT_POLICIES, } from './paragraphs.types.js'; // --------------------------------------------------------------------------- @@ -110,6 +120,8 @@ export interface ParagraphsAdapter { clearBorder(input: ParagraphsClearBorderInput, options?: MutationOptions): ParagraphMutationResult; setShading(input: ParagraphsSetShadingInput, options?: MutationOptions): ParagraphMutationResult; clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult; + setDirection(input: ParagraphsSetDirectionInput, options?: MutationOptions): ParagraphMutationResult; + clearDirection(input: ParagraphsClearDirectionInput, options?: MutationOptions): ParagraphMutationResult; } /** Public API surface for `format.paragraph.*` — direct paragraph formatting. */ @@ -134,6 +146,8 @@ export interface ParagraphFormatApi { clearBorder(input: ParagraphsClearBorderInput, options?: MutationOptions): ParagraphMutationResult; setShading(input: ParagraphsSetShadingInput, options?: MutationOptions): ParagraphMutationResult; clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult; + setDirection(input: ParagraphsSetDirectionInput, options?: MutationOptions): ParagraphMutationResult; + clearDirection(input: ParagraphsClearDirectionInput, options?: MutationOptions): ParagraphMutationResult; } /** Public API surface for `styles.paragraph.*` — Word-like paragraph style application operations. */ @@ -271,6 +285,8 @@ const SET_BORDER_KEYS = new Set(['target', 'side', 'style', 'color', 'size', 'sp const CLEAR_BORDER_KEYS = new Set(['target', 'side']); const SET_SHADING_KEYS = new Set(['target', 'fill', 'color', 'pattern']); const CLEAR_SHADING_KEYS = new Set(['target']); +const SET_DIRECTION_KEYS = new Set(['target', 'direction', 'alignmentPolicy']); +const CLEAR_DIRECTION_KEYS = new Set(['target']); // --------------------------------------------------------------------------- // Per-operation validators @@ -498,6 +514,25 @@ function validateClearShading(input: unknown): asserts input is ParagraphsClearS assertNoUnknownFields(input as Record, CLEAR_SHADING_KEYS, 'format.paragraph.clearShading'); } +function validateSetDirection(input: unknown): asserts input is ParagraphsSetDirectionInput { + const op = 'format.paragraph.setDirection'; + assertParagraphTarget(input, op); + assertNoUnknownFields(input as Record, SET_DIRECTION_KEYS, op); + const rec = input as Record; + if (rec.direction === undefined) { + throw new DocumentApiValidationError('INVALID_INPUT', `${op} requires a direction field.`); + } + assertOneOf(rec.direction, 'direction', PARAGRAPH_DIRECTIONS, op); + if (rec.alignmentPolicy !== undefined) { + assertOneOf(rec.alignmentPolicy, 'alignmentPolicy', ALIGNMENT_POLICIES, op); + } +} + +function validateClearDirection(input: unknown): asserts input is ParagraphsClearDirectionInput { + assertParagraphTarget(input, 'format.paragraph.clearDirection'); + assertNoUnknownFields(input as Record, CLEAR_DIRECTION_KEYS, 'format.paragraph.clearDirection'); +} + // --------------------------------------------------------------------------- // Execute functions — validate then delegate // --------------------------------------------------------------------------- @@ -672,3 +707,21 @@ export function executeParagraphsClearShading( validateClearShading(input); return adapter.clearShading(input, normalizeMutationOptions(options)); } + +export function executeParagraphsSetDirection( + adapter: ParagraphsAdapter, + input: ParagraphsSetDirectionInput, + options?: MutationOptions, +): ParagraphMutationResult { + validateSetDirection(input); + return adapter.setDirection(input, normalizeMutationOptions(options)); +} + +export function executeParagraphsClearDirection( + adapter: ParagraphsAdapter, + input: ParagraphsClearDirectionInput, + options?: MutationOptions, +): ParagraphMutationResult { + validateClearDirection(input); + return adapter.clearDirection(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/paragraphs/paragraphs.types.ts b/packages/document-api/src/paragraphs/paragraphs.types.ts index 906e043877..81794944f8 100644 --- a/packages/document-api/src/paragraphs/paragraphs.types.ts +++ b/packages/document-api/src/paragraphs/paragraphs.types.ts @@ -188,3 +188,25 @@ export interface ParagraphsSetShadingInput { export interface ParagraphsClearShadingInput { target: ParagraphTarget; } + +// --------------------------------------------------------------------------- +// Direction +// --------------------------------------------------------------------------- + +export const PARAGRAPH_DIRECTIONS = ['ltr', 'rtl'] as const; +export type ParagraphDirection = (typeof PARAGRAPH_DIRECTIONS)[number]; + +export const ALIGNMENT_POLICIES = ['preserve', 'matchDirection'] as const; +export type AlignmentPolicy = (typeof ALIGNMENT_POLICIES)[number]; + +/** paragraphs.setDirection */ +export interface ParagraphsSetDirectionInput { + target: ParagraphTarget; + direction: ParagraphDirection; + alignmentPolicy?: AlignmentPolicy; +} + +/** paragraphs.clearDirection */ +export interface ParagraphsClearDirectionInput { + target: ParagraphTarget; +} diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index 7f4869a339..15a2cf9feb 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -285,6 +285,7 @@ export const computeParagraphAttrs = ( keepLines: resolvedParagraphProperties.keepLines, floatAlignment: floatAlignment, pageBreakBefore: resolvedParagraphProperties.pageBreakBefore, + direction: resolvedParagraphProperties.rightToLeft ? 'rtl' : undefined, }; if (normalizedNumberingProperties && normalizedListRendering) { diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index db2165134a..4048c97a9e 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3068,7 +3068,7 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); - expect(paragraph.attrs?.direction).toBeUndefined(); + expect(paragraph.attrs?.direction).toBe('rtl'); expect(paragraph.attrs?.rtl).toBeUndefined(); expect(paragraph.attrs?.indent?.left).toBe(24); expect(paragraph.attrs?.indent?.right).toBe(12); 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 710b037236..2bd12cc0e1 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 @@ -42,6 +42,8 @@ import { paragraphsClearBorderWrapper, paragraphsSetShadingWrapper, paragraphsClearShadingWrapper, + paragraphsSetDirectionWrapper, + paragraphsClearDirectionWrapper, } from '../plan-engine/paragraphs-wrappers.js'; import { stylesApplyAdapter } from '../styles-adapter.js'; import { createTableWrapper } from '../plan-engine/create-table-wrapper.js'; @@ -2058,6 +2060,34 @@ const paragraphMutationVectors: Partial> = { return paragraphsClearShadingWrapper(editor, { target: PARAGRAPH_TARGET }); }, }, + 'format.paragraph.setDirection': { + throwCase: () => { + const { editor } = makeParagraphEditor(); + return paragraphsSetDirectionWrapper(editor, { target: MISSING_PARAGRAPH_TARGET, direction: 'rtl' }); + }, + failureCase: () => { + const { editor } = makeParagraphEditor({ rightToLeft: true }); + return paragraphsSetDirectionWrapper(editor, { target: PARAGRAPH_TARGET, direction: 'rtl' }); + }, + applyCase: () => { + const { editor } = makeParagraphEditor(); + return paragraphsSetDirectionWrapper(editor, { target: PARAGRAPH_TARGET, direction: 'rtl' }); + }, + }, + 'format.paragraph.clearDirection': { + throwCase: () => { + const { editor } = makeParagraphEditor(); + return paragraphsClearDirectionWrapper(editor, { target: MISSING_PARAGRAPH_TARGET }); + }, + failureCase: () => { + const { editor } = makeParagraphEditor(); + return paragraphsClearDirectionWrapper(editor, { target: PARAGRAPH_TARGET }); + }, + applyCase: () => { + const { editor } = makeParagraphEditor({ rightToLeft: true }); + return paragraphsClearDirectionWrapper(editor, { target: PARAGRAPH_TARGET }); + }, + }, }; const paragraphDryRunVectors: Partial unknown>> = { @@ -2251,6 +2281,26 @@ const paragraphDryRunVectors: Partial unknown>> = { expect(dispatch).not.toHaveBeenCalled(); return result; }, + 'format.paragraph.setDirection': () => { + const { editor, dispatch } = makeParagraphEditor(); + const result = paragraphsSetDirectionWrapper( + editor, + { target: PARAGRAPH_TARGET, direction: 'rtl' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'format.paragraph.clearDirection': () => { + const { editor, dispatch } = makeParagraphEditor({ rightToLeft: true }); + const result = paragraphsClearDirectionWrapper( + editor, + { target: PARAGRAPH_TARGET }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, }; function makeTocEditor(commandOverrides: Record = {}): Editor { diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts index 4ff3aeb2e3..c68a0b0850 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts @@ -43,6 +43,8 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('paragraphs.clearBorder'); expect(adapters).toHaveProperty('paragraphs.setShading'); expect(adapters).toHaveProperty('paragraphs.clearShading'); + expect(adapters).toHaveProperty('paragraphs.setDirection'); + expect(adapters).toHaveProperty('paragraphs.clearDirection'); expect(adapters).toHaveProperty('trackChanges.list'); expect(adapters).toHaveProperty('trackChanges.get'); expect(adapters).toHaveProperty('trackChanges.accept'); @@ -107,6 +109,8 @@ describe('assembleDocumentApiAdapters', () => { expect(typeof adapters.paragraphs.setStyle).toBe('function'); expect(typeof adapters.paragraphs.setAlignment).toBe('function'); expect(typeof adapters.paragraphs.setBorder).toBe('function'); + expect(typeof adapters.paragraphs.setDirection).toBe('function'); + expect(typeof adapters.paragraphs.clearDirection).toBe('function'); expect(typeof adapters.create.paragraph).toBe('function'); expect(typeof adapters.create.heading).toBe('function'); expect(typeof adapters.create.sectionBreak).toBe('function'); 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 8d2ae98f8a..1276ea285c 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -38,6 +38,8 @@ import { paragraphsClearBorderWrapper, paragraphsSetShadingWrapper, paragraphsClearShadingWrapper, + paragraphsSetDirectionWrapper, + paragraphsClearDirectionWrapper, } from './plan-engine/paragraphs-wrappers.js'; import { trackChangesListWrapper, @@ -405,6 +407,8 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters clearBorder: (input, options) => paragraphsClearBorderWrapper(editor, input, options), setShading: (input, options) => paragraphsSetShadingWrapper(editor, input, options), clearShading: (input, options) => paragraphsClearShadingWrapper(editor, input, options), + setDirection: (input, options) => paragraphsSetDirectionWrapper(editor, input, options), + clearDirection: (input, options) => paragraphsClearDirectionWrapper(editor, input, options), }, trackChanges: { list: (input) => trackChangesListWrapper(editor, input), diff --git a/packages/super-editor/src/document-api-adapters/helpers/sd-projection.ts b/packages/super-editor/src/document-api-adapters/helpers/sd-projection.ts index 040a06e2e2..c1dad5a706 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/sd-projection.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/sd-projection.ts @@ -1190,8 +1190,8 @@ function extractParagraphProps(attrs: ParagraphAttrs | undefined): SDParagraphPr props.shading = ppAny.shading; hasProps = true; } - if (ppAny.bidi !== undefined) { - props.bidi = ppAny.bidi; + if (ppAny.rightToLeft !== undefined) { + props.bidi = ppAny.rightToLeft; hasProps = true; } if (ppAny.markRunProps) { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts index 980e3c901a..c50ea3c09a 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/paragraphs-wrappers.ts @@ -35,6 +35,8 @@ import type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, + ParagraphsSetDirectionInput, + ParagraphsClearDirectionInput, ParagraphAlignment, } from '@superdoc/document-api'; import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.js'; @@ -743,3 +745,48 @@ export function paragraphsClearShadingWrapper( options, ); } + +export function paragraphsSetDirectionWrapper( + editor: Editor, + input: ParagraphsSetDirectionInput, + options?: MutationOptions, +): ParagraphMutationResult { + rejectTrackedMode('format.paragraph.setDirection', options); + const candidate = resolveParagraphBlock(editor, input.target); + return mutateParagraphProperties( + editor, + candidate, + 'format.paragraph.setDirection', + input.target, + (pPr) => { + const result = { ...pPr }; + result.rightToLeft = input.direction === 'rtl'; + if (input.alignmentPolicy === 'matchDirection') { + result.justification = input.direction === 'rtl' ? 'right' : 'left'; + } + return result; + }, + options, + ); +} + +export function paragraphsClearDirectionWrapper( + editor: Editor, + input: ParagraphsClearDirectionInput, + options?: MutationOptions, +): ParagraphMutationResult { + rejectTrackedMode('format.paragraph.clearDirection', options); + const candidate = resolveParagraphBlock(editor, input.target); + return mutateParagraphProperties( + editor, + candidate, + 'format.paragraph.clearDirection', + input.target, + (pPr) => { + const result = { ...pPr }; + delete result.rightToLeft; + return result; + }, + options, + ); +} diff --git a/packages/super-editor/src/document-api-adapters/structural-write-engine/node-materializer.ts b/packages/super-editor/src/document-api-adapters/structural-write-engine/node-materializer.ts index 5cbb02fb74..ebd5aa5cb8 100644 --- a/packages/super-editor/src/document-api-adapters/structural-write-engine/node-materializer.ts +++ b/packages/super-editor/src/document-api-adapters/structural-write-engine/node-materializer.ts @@ -970,7 +970,7 @@ function buildParagraphProperties(styleRef?: string, props?: any): Record; suppressAutoHyphens?: boolean; contextualSpacing?: boolean; + rightToLeft?: boolean; } /** List rendering metadata computed at runtime */ From 37f31a10c206e25b314cd327f935b6739fde466e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 12:28:46 -0700 Subject: [PATCH 2/2] chore: add doc api story test --- apps/docs/document-engine/sdks.mdx | 8 +- .../sdk/tools/intent_dispatch_generated.py | 2 + .../formatting/rtl-direction-roundtrip.ts | 222 ++++++++++++++++++ 3 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 tests/doc-api-stories/tests/formatting/rtl-direction-roundtrip.ts diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 9d374487f1..f796d3c0cf 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -561,7 +561,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.vanish` | `format vanish` | Set or clear the `vanish` inline run property on the target text range. | | `doc.format.webHidden` | `format web-hidden` | Set or clear the `webHidden` inline run property on the target text range. | | `doc.format.specVanish` | `format spec-vanish` | Set or clear the `specVanish` inline run property on the target text range. | -| `doc.format.rtl` | `format rtl` | Set or clear the `rtl` inline run property on the target text range. | +| `doc.format.rtl` | `format rtl` | Set or clear the `rtl` inline run property on the target text range. This does not change paragraph direction; use `format.paragraph.setDirection` for paragraph-level RTL. | | `doc.format.cs` | `format cs` | Set or clear the `cs` inline run property on the target text range. | | `doc.format.bCs` | `format b-cs` | Set or clear the `bCs` inline run property on the target text range. | | `doc.format.iCs` | `format i-cs` | Set or clear the `iCs` inline run property on the target text range. | @@ -599,6 +599,8 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.paragraph.clearBorder` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | | `doc.format.paragraph.setShading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | | `doc.format.paragraph.clearShading` | `format paragraph clear-shading` | Remove all paragraph shading. | +| `doc.format.paragraph.setDirection` | `format paragraph set-direction` | Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match. | +| `doc.format.paragraph.clearDirection` | `format paragraph clear-direction` | Remove explicit paragraph direction, reverting to inherited or default (LTR). | #### Create @@ -1009,7 +1011,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.vanish` | `format vanish` | Set or clear the `vanish` inline run property on the target text range. | | `doc.format.web_hidden` | `format web-hidden` | Set or clear the `webHidden` inline run property on the target text range. | | `doc.format.spec_vanish` | `format spec-vanish` | Set or clear the `specVanish` inline run property on the target text range. | -| `doc.format.rtl` | `format rtl` | Set or clear the `rtl` inline run property on the target text range. | +| `doc.format.rtl` | `format rtl` | Set or clear the `rtl` inline run property on the target text range. This does not change paragraph direction; use `format.paragraph.setDirection` for paragraph-level RTL. | | `doc.format.cs` | `format cs` | Set or clear the `cs` inline run property on the target text range. | | `doc.format.b_cs` | `format b-cs` | Set or clear the `bCs` inline run property on the target text range. | | `doc.format.i_cs` | `format i-cs` | Set or clear the `iCs` inline run property on the target text range. | @@ -1047,6 +1049,8 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.paragraph.clear_border` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | | `doc.format.paragraph.set_shading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | | `doc.format.paragraph.clear_shading` | `format paragraph clear-shading` | Remove all paragraph shading. | +| `doc.format.paragraph.set_direction` | `format paragraph set-direction` | Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match. | +| `doc.format.paragraph.clear_direction` | `format paragraph clear-direction` | Remove explicit paragraph direction, reverting to inherited or default (LTR). | #### Create diff --git a/packages/sdk/tools/intent_dispatch_generated.py b/packages/sdk/tools/intent_dispatch_generated.py index 11b63f1cc4..85b3b7e85f 100644 --- a/packages/sdk/tools/intent_dispatch_generated.py +++ b/packages/sdk/tools/intent_dispatch_generated.py @@ -51,6 +51,8 @@ def dispatch_intent_tool( return execute('doc.format.paragraph.setIndentation', rest) elif action == 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest) + elif action == 'set_direction': + return execute('doc.format.paragraph.setDirection', rest) else: raise SuperDocError(f'Unknown action for superdoc_format: {action}', code='TOOL_DISPATCH_NOT_FOUND', details={'toolName': 'superdoc_format', 'action': action}) elif tool_name == 'superdoc_create': diff --git a/tests/doc-api-stories/tests/formatting/rtl-direction-roundtrip.ts b/tests/doc-api-stories/tests/formatting/rtl-direction-roundtrip.ts new file mode 100644 index 0000000000..37a459da13 --- /dev/null +++ b/tests/doc-api-stories/tests/formatting/rtl-direction-roundtrip.ts @@ -0,0 +1,222 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +function extractParagraphXmls(documentXml: string): string[] { + return [...documentXml.matchAll(//g)].map((match) => match[0]); +} + +function countMatches(source: string, pattern: RegExp): number { + return [...source.matchAll(pattern)].length; +} + +function extractTextNodes(xml: string): string[] { + return [...xml.matchAll(/]*>([\s\S]*?)<\/w:t>/g)].map((match) => match[1]); +} + +function makeSessionId(label: string): string { + return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +describe('document-api story: rtl paragraph direction roundtrip', () => { + const { client, outPath } = useStoryHarness('formatting/rtl-direction-roundtrip', { + preserveResults: true, + }); + + const api = client as any; + + async function openBlankWithText(sessionId: string, text: string): Promise { + await api.doc.open({ sessionId }); + + const insertResult = unwrap(await api.doc.insert({ sessionId, value: text })); + expect(insertResult?.receipt?.success).toBe(true); + } + + async function queryTextMatch(sessionId: string, pattern: string): Promise { + const result = unwrap( + await api.doc.query.match({ + sessionId, + select: { + type: 'text', + pattern, + caseSensitive: true, + }, + require: 'first', + }), + ); + + const item = result?.items?.[0]; + expect(item).toBeDefined(); + expect(item?.matchKind).toBe('text'); + return item; + } + + async function paragraphTargetForText(sessionId: string, text: string) { + const match = await queryTextMatch(sessionId, text); + expect(match?.address?.kind).toBe('block'); + expect(match?.address?.nodeType).toBe('paragraph'); + return match.address; + } + + async function selectionTargetForText(sessionId: string, text: string) { + const match = await queryTextMatch(sessionId, text); + expect(match?.target?.kind).toBe('selection'); + return match.target; + } + + async function saveResult(sessionId: string, name: string): Promise { + const savePath = outPath(name); + await api.doc.save({ sessionId, out: savePath, force: true }); + return savePath; + } + + it('setDirection preserve: exports paragraph-level w:bidi without forcing alignment', async () => { + const sessionId = makeSessionId('rtl-set-direction-preserve'); + const paragraphText = 'RTL paragraph preserve case'; + + await openBlankWithText(sessionId, paragraphText); + const target = await paragraphTargetForText(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setDirection({ + sessionId, + target, + direction: 'rtl', + alignmentPolicy: 'preserve', + }), + ); + + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'set-direction-preserve.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]).toContain(paragraphText); + expect(paragraphs[0]).toContain(''); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(0); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(0); + expect(extractTextNodes(paragraphs[0])).toEqual([paragraphText]); + }); + + it('setDirection matchDirection: exports paragraph-level w:bidi and right justification', async () => { + const sessionId = makeSessionId('rtl-set-direction-match'); + const paragraphText = 'RTL paragraph matchDirection case'; + + await openBlankWithText(sessionId, paragraphText); + const target = await paragraphTargetForText(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setDirection({ + sessionId, + target, + direction: 'rtl', + alignmentPolicy: 'matchDirection', + }), + ); + + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'set-direction-match-direction.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]).toContain(paragraphText); + expect(paragraphs[0]).toContain(''); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(0); + expect(extractTextNodes(paragraphs[0])).toEqual([paragraphText]); + }); + + it('clearDirection: removes paragraph-level w:bidi after a preserve-direction mutation', async () => { + const sessionId = makeSessionId('rtl-clear-direction'); + const paragraphText = 'RTL paragraph clearDirection case'; + + await openBlankWithText(sessionId, paragraphText); + const target = await paragraphTargetForText(sessionId, paragraphText); + + const setResult = unwrap( + await api.doc.format.paragraph.setDirection({ + sessionId, + target, + direction: 'rtl', + alignmentPolicy: 'preserve', + }), + ); + expect(setResult?.success).toBe(true); + + const clearResult = unwrap( + await api.doc.format.paragraph.clearDirection({ + sessionId, + target, + }), + ); + expect(clearResult?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'clear-direction.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]).toContain(paragraphText); + expect(countMatches(paragraphs[0], //g)).toBe(0); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(0); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(0); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(0); + expect(extractTextNodes(paragraphs[0])).toEqual([paragraphText]); + }); + + it('format.rtl: exports run-level w:rtl without changing paragraph direction', async () => { + const sessionId = makeSessionId('rtl-inline-run'); + const paragraphText = 'Inline RTL case: abc مرحبا xyz'; + const selectedText = 'مرحبا'; + + await openBlankWithText(sessionId, paragraphText); + const target = await selectionTargetForText(sessionId, selectedText); + + const result = unwrap( + await api.doc.format.rtl({ + sessionId, + target, + value: true, + }), + ); + + expect(result?.receipt?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'inline-rtl.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + const rtlRunRegex = new RegExp( + `[\\s\\S]*?]*\\/>[\\s\\S]*?]*>${escapeForRegex(selectedText)}[\\s\\S]*?<\\/w:r>`, + ); + + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]).toContain(selectedText); + expect(paragraphs[0]).not.toContain(''); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(0); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(0); + expect(paragraphs[0]).toMatch(rtlRunRegex); + expect(extractTextNodes(paragraphs[0])).toEqual(['Inline RTL case: abc ', selectedText, ' xyz']); + }); +});