From 8af4b8670854ab75ce946c74bc19547539a55f32 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 12 Mar 2026 21:43:26 -0700 Subject: [PATCH 1/9] feat(document-api): improve cross block selection and deleting --- .../reference/_generated-manifest.json | 2 +- .../document-api/reference/blocks/delete.mdx | 4 +- apps/docs/document-api/reference/delete.mdx | 78 +- .../document-api/reference/format/apply.mdx | 1500 +++++++++-------- .../document-api/reference/format/b-cs.mdx | 82 +- .../document-api/reference/format/bold.mdx | 82 +- .../document-api/reference/format/border.mdx | 166 +- .../document-api/reference/format/caps.mdx | 82 +- .../reference/format/char-scale.mdx | 86 +- .../document-api/reference/format/color.mdx | 88 +- .../format/contextual-alternates.mdx | 82 +- .../docs/document-api/reference/format/cs.mdx | 82 +- .../document-api/reference/format/dstrike.mdx | 82 +- .../reference/format/east-asian-layout.mdx | 184 +- .../docs/document-api/reference/format/em.mdx | 88 +- .../document-api/reference/format/emboss.mdx | 82 +- .../reference/format/fit-text.mdx | 128 +- .../reference/format/font-family.mdx | 88 +- .../reference/format/font-size-cs.mdx | 86 +- .../reference/format/font-size.mdx | 86 +- .../reference/format/highlight.mdx | 88 +- .../document-api/reference/format/i-cs.mdx | 82 +- .../document-api/reference/format/imprint.mdx | 82 +- .../document-api/reference/format/italic.mdx | 82 +- .../document-api/reference/format/kerning.mdx | 86 +- .../document-api/reference/format/lang.mdx | 150 +- .../reference/format/letter-spacing.mdx | 86 +- .../reference/format/ligatures.mdx | 88 +- .../reference/format/num-form.mdx | 88 +- .../reference/format/num-spacing.mdx | 88 +- .../document-api/reference/format/o-math.mdx | 82 +- .../document-api/reference/format/outline.mdx | 82 +- .../reference/format/position.mdx | 86 +- .../document-api/reference/format/r-fonts.mdx | 270 +-- .../document-api/reference/format/r-style.mdx | 88 +- .../document-api/reference/format/rtl.mdx | 82 +- .../document-api/reference/format/shading.mdx | 150 +- .../document-api/reference/format/shadow.mdx | 82 +- .../reference/format/small-caps.mdx | 82 +- .../reference/format/snap-to-grid.mdx | 82 +- .../reference/format/spec-vanish.mdx | 82 +- .../document-api/reference/format/strike.mdx | 82 +- .../reference/format/stylistic-sets.mdx | 114 +- .../reference/format/underline.mdx | 152 +- .../document-api/reference/format/vanish.mdx | 82 +- .../reference/format/vert-align.mdx | 92 +- .../reference/format/web-hidden.mdx | 82 +- apps/docs/document-api/reference/index.mdx | 6 +- apps/docs/document-api/reference/insert.mdx | 37 +- .../reference/mutations/apply.mdx | 57 +- .../reference/mutations/preview.mdx | 57 +- .../document-api/reference/query/match.mdx | 19 +- apps/docs/document-api/reference/replace.mdx | 340 +++- .../scripts/lib/reference-docs-artifacts.ts | 89 +- .../src/contract/operation-definitions.ts | 15 +- packages/document-api/src/contract/schemas.ts | 134 +- packages/document-api/src/delete/delete.ts | 104 +- .../document-api/src/format/format.test.ts | 52 +- packages/document-api/src/format/format.ts | 144 +- packages/document-api/src/index.test.ts | 373 ++-- packages/document-api/src/index.ts | 19 +- .../document-api/src/invoke/invoke.test.ts | 32 +- .../src/overview-examples.test.ts | 35 +- packages/document-api/src/receipt-bridge.ts | 32 +- packages/document-api/src/replace/replace.ts | 179 +- .../document-api/src/selection-mutation.ts | 53 + packages/document-api/src/types/address.ts | 59 + .../src/types/mutation-plan.types.ts | 23 +- .../src/types/query-match.types.ts | 8 + packages/document-api/src/types/query.ts | 9 +- packages/document-api/src/types/receipt.ts | 10 +- .../document-api/src/types/sd-contract.ts | 8 +- .../src/types/structural-input.ts | 8 +- .../validation/selection-target-validator.ts | 46 + packages/document-api/src/write/write.ts | 32 +- .../assemble-adapters.test.ts | 4 +- .../assemble-adapters.ts | 6 +- .../src/document-api-adapters/find-adapter.ts | 38 +- .../document-api-adapters/format-adapter.ts | 24 +- .../helpers/expand-delete-selection.ts | 142 ++ .../helpers/selection-target-resolver.ts | 183 ++ .../plan-engine/compiler.ts | 155 +- .../plan-engine/executor-registry.types.ts | 27 +- .../plan-engine/index.ts | 16 +- .../plan-engine/plan-wrappers.ts | 740 ++++++-- .../plan-engine/preview.ts | 3 + .../plan-engine/query-match-adapter.ts | 43 +- .../plan-engine/register-executors.ts | 50 +- .../structural-write-engine/index.ts | 15 +- .../structural-write-engine.test.ts | 347 +++- 90 files changed, 6638 insertions(+), 2985 deletions(-) create mode 100644 packages/document-api/src/selection-mutation.ts create mode 100644 packages/document-api/src/validation/selection-target-validator.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/expand-delete-selection.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/selection-target-resolver.ts diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 61443f90f1..61731c51a7 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -940,5 +940,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "eef969b01d52d99044ea5eb9c17085c5e31768001ced01042c4ebd90e6f00a24" + "sourceHash": "884de1988dd0626237a07743631deedd011a76154bcdd46d931d809d725c0804" } diff --git a/apps/docs/document-api/reference/blocks/delete.mdx b/apps/docs/document-api/reference/blocks/delete.mdx index e50313e910..5352c5bb64 100644 --- a/apps/docs/document-api/reference/blocks/delete.mdx +++ b/apps/docs/document-api/reference/blocks/delete.mdx @@ -1,7 +1,7 @@ --- title: blocks.delete sidebarTitle: blocks.delete -description: Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. +description: Delete an entire block node (paragraph, heading, list item, table, or sdt) deterministically by block address. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Delete an entire block node (paragraph, heading, list item, table, ## Summary -Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. +Delete an entire block node (paragraph, heading, list item, table, or sdt) deterministically by block address. - Operation ID: `blocks.delete` - API member path: `editor.doc.blocks.delete(...)` diff --git a/apps/docs/document-api/reference/delete.mdx b/apps/docs/document-api/reference/delete.mdx index 39793bdbb1..db16af11f2 100644 --- a/apps/docs/document-api/reference/delete.mdx +++ b/apps/docs/document-api/reference/delete.mdx @@ -1,7 +1,7 @@ --- title: delete sidebarTitle: delete -description: Delete content at a target position. +description: Delete content at a contiguous document selection. Accepts a SelectionTarget or mutation-ready ref. Supports cross-block deletion and optional block-edge expansion via behavior mode. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Delete content at a target position. ## Summary -Delete content at a target position. +Delete content at a contiguous document selection. Accepts a SelectionTarget or mutation-ready ref. Supports cross-block deletion and optional block-edge expansion via behavior mode. - Operation ID: `delete` - API member path: `editor.doc.delete(...)` @@ -22,29 +22,35 @@ Delete content at a target position. ## Expected result -Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the target range is already empty. +Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the target range is collapsed or empty. ## Input fields | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `behavior` | DeleteBehavior | no | DeleteBehavior | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | ### Example request ```json { + "behavior": "selection", "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } } } @@ -68,6 +74,10 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -96,6 +106,10 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -129,6 +143,19 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -155,6 +182,7 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the - `TARGET_NOT_FOUND` - `CAPABILITY_UNAVAILABLE` - `INVALID_TARGET` +- `INVALID_INPUT` ## Non-applied failure codes @@ -165,16 +193,20 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" + }, + { + "additionalProperties": false, + "properties": { + "behavior": { + "$ref": "#/$defs/DeleteBehavior" + } + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/apply.mdx b/apps/docs/document-api/reference/format/apply.mdx index f1845833b2..7fb357d8be 100644 --- a/apps/docs/document-api/reference/format/apply.mdx +++ b/apps/docs/document-api/reference/format/apply.mdx @@ -72,12 +72,11 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe | `inline.vanish` | boolean \\| null | no | One of: boolean, null | | `inline.vertAlign` | enum \\| null | no | One of: enum, null | | `inline.webHidden` | boolean \\| null | no | One of: boolean, null | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | ### Example request @@ -88,11 +87,16 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe "italic": true }, "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } } } @@ -116,6 +120,10 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -144,6 +152,10 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -177,6 +189,19 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -214,826 +239,829 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe ```json { - "additionalProperties": false, - "properties": { - "inline": { + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" + }, + { "additionalProperties": false, - "minProperties": 1, "properties": { - "bCs": { - "oneOf": [ - { - "type": "boolean" + "inline": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bCs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "bold": { - "oneOf": [ - { - "type": "boolean" + "bold": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "border": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "border": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "space": { - "oneOf": [ - { - "type": "number" + "space": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "sz": { - "oneOf": [ - { - "type": "number" + "sz": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" + ] }, - { - "type": "null" - } - ] - }, - "caps": { - "oneOf": [ - { - "type": "boolean" + "caps": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "charScale": { - "oneOf": [ - { - "type": "number" + "charScale": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "color": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "contextualAlternates": { - "oneOf": [ - { - "type": "boolean" + "contextualAlternates": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "cs": { - "oneOf": [ - { - "type": "boolean" + "cs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "dstrike": { - "oneOf": [ - { - "type": "boolean" + "dstrike": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "eastAsianLayout": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "combine": { - "oneOf": [ - { - "type": "boolean" + "eastAsianLayout": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "combine": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "combineBrackets": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "combineBrackets": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "id": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "vert": { - "oneOf": [ - { - "type": "boolean" + "vert": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "vertCompress": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "vertCompress": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" + ] }, - { - "type": "null" - } - ] - }, - "em": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "em": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "emboss": { - "oneOf": [ - { - "type": "boolean" + "emboss": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "fitText": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "id": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "fitText": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" + ] }, - { - "type": "null" - } - ] - }, - "fontFamily": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "fontFamily": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "fontSize": { - "oneOf": [ - { - "type": "number" + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "fontSizeCs": { - "oneOf": [ - { - "type": "number" + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "highlight": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "highlight": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "iCs": { - "oneOf": [ - { - "type": "boolean" + "iCs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "imprint": { - "oneOf": [ - { - "type": "boolean" + "imprint": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "italic": { - "oneOf": [ - { - "type": "boolean" + "italic": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "kerning": { - "oneOf": [ - { - "type": "number" + "kerning": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "lang": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "bidi": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "lang": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bidi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "eastAsia": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" + ] }, - { - "type": "null" - } - ] - }, - "letterSpacing": { - "oneOf": [ - { - "type": "number" + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "ligatures": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "ligatures": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "numForm": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "numForm": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "numSpacing": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "numSpacing": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "oMath": { - "oneOf": [ - { - "type": "boolean" + "oMath": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "outline": { - "oneOf": [ - { - "type": "boolean" + "outline": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "position": { - "oneOf": [ - { - "type": "number" + "position": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "rFonts": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ascii": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "rFonts": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "ascii": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "asciiTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "asciiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "cs": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "cs": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "csTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "csTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "eastAsia": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "eastAsiaTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "eastAsiaTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "hAnsi": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "hAnsi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "hAnsiTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "hAnsiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "hint": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "hint": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" + ] }, - { - "type": "null" - } - ] - }, - "rStyle": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "rStyle": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "rtl": { - "oneOf": [ - { - "type": "boolean" + "rtl": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "shading": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "shading": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "fill": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "fill": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" + ] }, - { - "type": "null" - } - ] - }, - "shadow": { - "oneOf": [ - { - "type": "boolean" + "shadow": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "smallCaps": { - "oneOf": [ - { - "type": "boolean" + "smallCaps": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "snapToGrid": { - "oneOf": [ - { - "type": "boolean" + "snapToGrid": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "specVanish": { - "oneOf": [ - { - "type": "boolean" + "specVanish": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "strike": { - "oneOf": [ - { - "type": "boolean" + "strike": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "stylisticSets": { - "oneOf": [ - { - "items": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" + "stylisticSets": { + "oneOf": [ + { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "val": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "type": "object" }, - "val": { - "type": "boolean" - } + "minItems": 1, + "type": "array" }, - "required": [ - "id" - ], - "type": "object" - }, - "minItems": 1, - "type": "array" - }, - { - "type": "null" - } - ] - }, - "underline": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "type": "null" + } + ] }, - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + "underline": { + "oneOf": [ + { + "type": "boolean" }, - "style": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" }, - "themeColor": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "style": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "themeColor": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "type": "object" - } - ] - }, - "vanish": { - "oneOf": [ - { - "type": "boolean" + ] }, - { - "type": "null" - } - ] - }, - "vertAlign": { - "oneOf": [ - { - "enum": [ - "superscript", - "subscript", - "baseline" + "vanish": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } ] }, - { - "type": "null" - } - ] - }, - "webHidden": { - "oneOf": [ - { - "type": "boolean" + "vertAlign": { + "oneOf": [ + { + "enum": [ + "superscript", + "subscript", + "baseline" + ] + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "webHidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } }, + "required": [ + "inline" + ], "type": "object" - }, - "target": { - "$ref": "#/$defs/TextAddress" } - }, - "required": [ - "target", - "inline" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/b-cs.mdx b/apps/docs/document-api/reference/format/b-cs.mdx index 861b742e79..a53efa3997 100644 --- a/apps/docs/document-api/reference/format/b-cs.mdx +++ b/apps/docs/document-api/reference/format/b-cs.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/bold.mdx b/apps/docs/document-api/reference/format/bold.mdx index 6464e63472..f34fbbc3dc 100644 --- a/apps/docs/document-api/reference/format/bold.mdx +++ b/apps/docs/document-api/reference/format/bold.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/border.mdx b/apps/docs/document-api/reference/format/border.mdx index cdd36da9b3..96bc55a219 100644 --- a/apps/docs/document-api/reference/format/border.mdx +++ b/apps/docs/document-api/reference/format/border.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | object \\| null | yes | One of: object, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": { @@ -73,6 +77,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -101,6 +109,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -134,6 +146,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -171,73 +196,76 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "space": { - "oneOf": [ - { - "type": "number" + "space": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "sz": { - "oneOf": [ - { - "type": "number" + "sz": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" - }, - { - "type": "null" + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/caps.mdx b/apps/docs/document-api/reference/format/caps.mdx index af8552b57c..9e52f9f5cf 100644 --- a/apps/docs/document-api/reference/format/caps.mdx +++ b/apps/docs/document-api/reference/format/caps.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/char-scale.mdx b/apps/docs/document-api/reference/format/char-scale.mdx index 74bf2ba9d6..c929ddc99d 100644 --- a/apps/docs/document-api/reference/format/char-scale.mdx +++ b/apps/docs/document-api/reference/format/char-scale.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | number \\| null | yes | One of: number, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": 12.5 @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,27 +193,30 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/color.mdx b/apps/docs/document-api/reference/format/color.mdx index d552147e11..7e5ce227d8 100644 --- a/apps/docs/document-api/reference/format/color.mdx +++ b/apps/docs/document-api/reference/format/color.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/contextual-alternates.mdx b/apps/docs/document-api/reference/format/contextual-alternates.mdx index 5302ff2f3a..6601943155 100644 --- a/apps/docs/document-api/reference/format/contextual-alternates.mdx +++ b/apps/docs/document-api/reference/format/contextual-alternates.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/cs.mdx b/apps/docs/document-api/reference/format/cs.mdx index 43e03b216b..9d2a74465a 100644 --- a/apps/docs/document-api/reference/format/cs.mdx +++ b/apps/docs/document-api/reference/format/cs.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/dstrike.mdx b/apps/docs/document-api/reference/format/dstrike.mdx index e4d6b4af0e..7dd43f06d4 100644 --- a/apps/docs/document-api/reference/format/dstrike.mdx +++ b/apps/docs/document-api/reference/format/dstrike.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/east-asian-layout.mdx b/apps/docs/document-api/reference/format/east-asian-layout.mdx index f16ca354a2..ef555bcaf6 100644 --- a/apps/docs/document-api/reference/format/east-asian-layout.mdx +++ b/apps/docs/document-api/reference/format/east-asian-layout.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | object \\| null | yes | One of: object, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": { @@ -73,6 +77,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -101,6 +109,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -134,6 +146,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -171,83 +196,86 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "combine": { - "oneOf": [ - { - "type": "boolean" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "combine": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "combineBrackets": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "combineBrackets": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "id": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "vert": { - "oneOf": [ - { - "type": "boolean" + "vert": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "vertCompress": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "vertCompress": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" - }, - { - "type": "null" + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/em.mdx b/apps/docs/document-api/reference/format/em.mdx index bcbec86e55..5561ca3788 100644 --- a/apps/docs/document-api/reference/format/em.mdx +++ b/apps/docs/document-api/reference/format/em.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/emboss.mdx b/apps/docs/document-api/reference/format/emboss.mdx index 6099789378..38f64ffbb5 100644 --- a/apps/docs/document-api/reference/format/emboss.mdx +++ b/apps/docs/document-api/reference/format/emboss.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/fit-text.mdx b/apps/docs/document-api/reference/format/fit-text.mdx index 8db45726a7..5f924f4eb1 100644 --- a/apps/docs/document-api/reference/format/fit-text.mdx +++ b/apps/docs/document-api/reference/format/fit-text.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | object \\| null | yes | One of: object, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": { @@ -73,6 +77,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -101,6 +109,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -134,6 +146,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -171,52 +196,55 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "id": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" - }, - { - "type": "null" + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/font-family.mdx b/apps/docs/document-api/reference/format/font-family.mdx index 28e4dccd97..b070bfcc25 100644 --- a/apps/docs/document-api/reference/format/font-family.mdx +++ b/apps/docs/document-api/reference/format/font-family.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/font-size-cs.mdx b/apps/docs/document-api/reference/format/font-size-cs.mdx index 2cc8870655..8e0245a068 100644 --- a/apps/docs/document-api/reference/format/font-size-cs.mdx +++ b/apps/docs/document-api/reference/format/font-size-cs.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | number \\| null | yes | One of: number, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": 12.5 @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,27 +193,30 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/font-size.mdx b/apps/docs/document-api/reference/format/font-size.mdx index 137a7a89f1..56887154a5 100644 --- a/apps/docs/document-api/reference/format/font-size.mdx +++ b/apps/docs/document-api/reference/format/font-size.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | number \\| null | yes | One of: number, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": 12.5 @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,27 +193,30 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/highlight.mdx b/apps/docs/document-api/reference/format/highlight.mdx index 0b1b7dde86..e77aa0ce1f 100644 --- a/apps/docs/document-api/reference/format/highlight.mdx +++ b/apps/docs/document-api/reference/format/highlight.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/i-cs.mdx b/apps/docs/document-api/reference/format/i-cs.mdx index ca679d5388..d81f96fcb0 100644 --- a/apps/docs/document-api/reference/format/i-cs.mdx +++ b/apps/docs/document-api/reference/format/i-cs.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/imprint.mdx b/apps/docs/document-api/reference/format/imprint.mdx index a1fae6b190..ddabcf1af3 100644 --- a/apps/docs/document-api/reference/format/imprint.mdx +++ b/apps/docs/document-api/reference/format/imprint.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/italic.mdx b/apps/docs/document-api/reference/format/italic.mdx index cd754443f6..f07d79e02b 100644 --- a/apps/docs/document-api/reference/format/italic.mdx +++ b/apps/docs/document-api/reference/format/italic.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/kerning.mdx b/apps/docs/document-api/reference/format/kerning.mdx index 91202f424d..cc01f84966 100644 --- a/apps/docs/document-api/reference/format/kerning.mdx +++ b/apps/docs/document-api/reference/format/kerning.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | number \\| null | yes | One of: number, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": 12.5 @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,27 +193,30 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/lang.mdx b/apps/docs/document-api/reference/format/lang.mdx index c575463dc6..ccefa4508e 100644 --- a/apps/docs/document-api/reference/format/lang.mdx +++ b/apps/docs/document-api/reference/format/lang.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | object \\| null | yes | One of: object, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": { @@ -73,6 +77,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -101,6 +109,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -134,6 +146,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -171,64 +196,67 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "bidi": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bidi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "eastAsia": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" - }, - { - "type": "null" + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/letter-spacing.mdx b/apps/docs/document-api/reference/format/letter-spacing.mdx index ac3f16bbdc..2f64e23055 100644 --- a/apps/docs/document-api/reference/format/letter-spacing.mdx +++ b/apps/docs/document-api/reference/format/letter-spacing.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | number \\| null | yes | One of: number, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": 12.5 @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,27 +193,30 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/ligatures.mdx b/apps/docs/document-api/reference/format/ligatures.mdx index 51fd2a5909..d991702a8e 100644 --- a/apps/docs/document-api/reference/format/ligatures.mdx +++ b/apps/docs/document-api/reference/format/ligatures.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/num-form.mdx b/apps/docs/document-api/reference/format/num-form.mdx index 37a1e7b2a1..6a028054b7 100644 --- a/apps/docs/document-api/reference/format/num-form.mdx +++ b/apps/docs/document-api/reference/format/num-form.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/num-spacing.mdx b/apps/docs/document-api/reference/format/num-spacing.mdx index dfe65b619d..25c0c716ec 100644 --- a/apps/docs/document-api/reference/format/num-spacing.mdx +++ b/apps/docs/document-api/reference/format/num-spacing.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/o-math.mdx b/apps/docs/document-api/reference/format/o-math.mdx index 0a18888ff5..3c53f68c7a 100644 --- a/apps/docs/document-api/reference/format/o-math.mdx +++ b/apps/docs/document-api/reference/format/o-math.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/outline.mdx b/apps/docs/document-api/reference/format/outline.mdx index f7c5b2e9e8..73853c338e 100644 --- a/apps/docs/document-api/reference/format/outline.mdx +++ b/apps/docs/document-api/reference/format/outline.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/position.mdx b/apps/docs/document-api/reference/format/position.mdx index 2c9ab4f533..ded503db1b 100644 --- a/apps/docs/document-api/reference/format/position.mdx +++ b/apps/docs/document-api/reference/format/position.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | number \\| null | yes | One of: number, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": 12.5 @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,27 +193,30 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/r-fonts.mdx b/apps/docs/document-api/reference/format/r-fonts.mdx index d4850ef060..7a553ef0e3 100644 --- a/apps/docs/document-api/reference/format/r-fonts.mdx +++ b/apps/docs/document-api/reference/format/r-fonts.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | object \\| null | yes | One of: object, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": { @@ -73,6 +77,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -101,6 +109,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -134,6 +146,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -171,130 +196,133 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ascii": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "ascii": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "asciiTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "asciiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "cs": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "cs": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "csTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "csTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "eastAsia": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "eastAsiaTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "eastAsiaTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "hAnsi": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "hAnsi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "hAnsiTheme": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "hAnsiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "hint": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "hint": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" - }, - { - "type": "null" + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/r-style.mdx b/apps/docs/document-api/reference/format/r-style.mdx index 4697abc726..6eab873abb 100644 --- a/apps/docs/document-api/reference/format/r-style.mdx +++ b/apps/docs/document-api/reference/format/r-style.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | string \\| null | yes | One of: string, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "example" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,28 +193,31 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/rtl.mdx b/apps/docs/document-api/reference/format/rtl.mdx index 6c178fbab5..7b543ac7e1 100644 --- a/apps/docs/document-api/reference/format/rtl.mdx +++ b/apps/docs/document-api/reference/format/rtl.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/shading.mdx b/apps/docs/document-api/reference/format/shading.mdx index 5212e177ae..358551aa15 100644 --- a/apps/docs/document-api/reference/format/shading.mdx +++ b/apps/docs/document-api/reference/format/shading.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | object \\| null | yes | One of: object, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": { @@ -73,6 +77,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -101,6 +109,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -134,6 +146,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -171,64 +196,67 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "fill": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + "fill": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" }, - "val": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "type": "object" - }, - { - "type": "null" + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/shadow.mdx b/apps/docs/document-api/reference/format/shadow.mdx index 54e45f09cd..ca0a2d5c34 100644 --- a/apps/docs/document-api/reference/format/shadow.mdx +++ b/apps/docs/document-api/reference/format/shadow.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/small-caps.mdx b/apps/docs/document-api/reference/format/small-caps.mdx index 9d8723aefc..763f025f79 100644 --- a/apps/docs/document-api/reference/format/small-caps.mdx +++ b/apps/docs/document-api/reference/format/small-caps.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/snap-to-grid.mdx b/apps/docs/document-api/reference/format/snap-to-grid.mdx index 9ad3a31303..dbae541da0 100644 --- a/apps/docs/document-api/reference/format/snap-to-grid.mdx +++ b/apps/docs/document-api/reference/format/snap-to-grid.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/spec-vanish.mdx b/apps/docs/document-api/reference/format/spec-vanish.mdx index 223828bbd3..46239814f5 100644 --- a/apps/docs/document-api/reference/format/spec-vanish.mdx +++ b/apps/docs/document-api/reference/format/spec-vanish.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/strike.mdx b/apps/docs/document-api/reference/format/strike.mdx index 322c25ec31..46c2a031e7 100644 --- a/apps/docs/document-api/reference/format/strike.mdx +++ b/apps/docs/document-api/reference/format/strike.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/stylistic-sets.mdx b/apps/docs/document-api/reference/format/stylistic-sets.mdx index 4fe7482702..5e8a0d12d9 100644 --- a/apps/docs/document-api/reference/format/stylistic-sets.mdx +++ b/apps/docs/document-api/reference/format/stylistic-sets.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | object[] \\| null | yes | One of: object[], null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": [ @@ -75,6 +79,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -103,6 +111,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -136,6 +148,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -173,43 +198,46 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "items": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "val": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "type": "object" }, - "val": { - "type": "boolean" - } + "minItems": 1, + "type": "array" }, - "required": [ - "id" - ], - "type": "object" - }, - "minItems": 1, - "type": "array" - }, - { - "type": "null" + { + "type": "null" + } + ] } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/underline.mdx b/apps/docs/document-api/reference/format/underline.mdx index b00cdbed58..7523bb9fdc 100644 --- a/apps/docs/document-api/reference/format/underline.mdx +++ b/apps/docs/document-api/reference/format/underline.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null \\| object | no | One of: boolean, null, object | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,66 +193,67 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" }, - "style": { - "oneOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" }, - "themeColor": { - "oneOf": [ - { - "minLength": 1, - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "style": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "themeColor": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "type": "object" + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/vanish.mdx b/apps/docs/document-api/reference/format/vanish.mdx index 73af0bcaa4..ab44297641 100644 --- a/apps/docs/document-api/reference/format/vanish.mdx +++ b/apps/docs/document-api/reference/format/vanish.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/vert-align.mdx b/apps/docs/document-api/reference/format/vert-align.mdx index 8cbaa750ab..74d8adf43f 100644 --- a/apps/docs/document-api/reference/format/vert-align.mdx +++ b/apps/docs/document-api/reference/format/vert-align.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | enum \\| null | yes | One of: enum, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": "superscript" @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,31 +193,34 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "enum": [ - "superscript", - "subscript", - "baseline" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "enum": [ + "superscript", + "subscript", + "baseline" + ] + }, + { + "type": "null" + } ] - }, - { - "type": "null" } - ] + }, + "required": [ + "value" + ], + "type": "object" } - }, - "required": [ - "target", - "value" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/format/web-hidden.mdx b/apps/docs/document-api/reference/format/web-hidden.mdx index a02319e0fd..ca93221879 100644 --- a/apps/docs/document-api/reference/format/web-hidden.mdx +++ b/apps/docs/document-api/reference/format/web-hidden.mdx @@ -28,12 +28,11 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `value` | boolean \\| null | no | One of: boolean, null | ### Example request @@ -41,11 +40,16 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "value": true @@ -70,6 +74,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -98,6 +106,10 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `resolution.requestedTarget.range` | Range | no | Range | | `resolution.requestedTarget.range.end` | integer | no | | | `resolution.requestedTarget.range.start` | integer | no | | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | TextAddress | yes | TextAddress | | `resolution.target.blockId` | string | yes | | | `resolution.target.kind` | `"text"` | yes | Constant: `"text"` | @@ -131,6 +143,19 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli "start": 0 } }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "blockId": "block-abc123", "kind": "text", @@ -168,26 +193,27 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" }, - "value": { - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "null" + { + "additionalProperties": false, + "properties": { + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } - ] + }, + "type": "object" } - }, - "required": [ - "target" - ], - "type": "object" + ] } ``` diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index fdfa645ca1..00f19bd0ed 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -69,14 +69,14 @@ The tables below are grouped by namespace. | info | editor.doc.info(...) | Return document metadata including revision, node count, and capabilities. | | clearContent | editor.doc.clearContent(...) | Clear all document body content, leaving a single empty paragraph. | | insert | editor.doc.insert(...) | Insert content at a target position, or at the end of the document when target is omitted. Accepts two input shapes: legacy string-based (value + type) or structural SDFragment (content). Supports text (default), markdown, and html content types via the `type` field in legacy mode. Structural mode accepts an SDFragment with typed nodes (paragraphs, tables, images, etc.). | -| replace | editor.doc.replace(...) | Replace content at a target position with new content. Accepts two input shapes: legacy string-based (text) or structural SDFragment (content). Structural mode replaces the target range with typed nodes (paragraphs, tables, images, etc.). | -| delete | editor.doc.delete(...) | Delete content at a target position. | +| replace | editor.doc.replace(...) | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts an SDAddress, SelectionTarget, or ref plus SDFragment content. | +| delete | editor.doc.delete(...) | Delete content at a contiguous document selection. Accepts a SelectionTarget or mutation-ready ref. Supports cross-block deletion and optional block-edge expansion via behavior mode. | #### Blocks | Operation | API member path | Description | | --- | --- | --- | -| blocks.delete | editor.doc.blocks.delete(...) | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | +| blocks.delete | editor.doc.blocks.delete(...) | Delete an entire block node (paragraph, heading, list item, table, or sdt) deterministically by block address. | #### Capabilities diff --git a/apps/docs/document-api/reference/insert.mdx b/apps/docs/document-api/reference/insert.mdx index 44c4e9309e..aea983981b 100644 --- a/apps/docs/document-api/reference/insert.mdx +++ b/apps/docs/document-api/reference/insert.mdx @@ -56,7 +56,7 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ## Output fields -### Variant 1 (success=true) +### Variant 1 (resolution.selectionTarget.kind="selection") | Field | Type | Required | Description | | --- | --- | --- | --- | @@ -77,6 +77,10 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.requestedTarget.nodeId` | string | no | | | `resolution.requestedTarget.path` | string \\| integer[] | no | | | `resolution.requestedTarget.stability` | enum | no | `"stable"`, `"ephemeral"` | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | object | no | | | `resolution.target.anchor` | object | no | | | `resolution.target.anchor.end` | object | no | | @@ -92,7 +96,7 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.target.stability` | enum | no | `"stable"`, `"ephemeral"` | | `success` | `true` | yes | Constant: `true` | -### Variant 2 (success=false) +### Variant 2 (resolution.selectionTarget.kind="selection") | Field | Type | Required | Description | | --- | --- | --- | --- | @@ -117,6 +121,10 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.requestedTarget.nodeId` | string | no | | | `resolution.requestedTarget.path` | string \\| integer[] | no | | | `resolution.requestedTarget.stability` | enum | no | `"stable"`, `"ephemeral"` | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | object | no | | | `resolution.target.anchor` | object | no | | | `resolution.target.anchor.end` | object | no | | @@ -156,6 +164,19 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the "nodeId": "node-def456", "stability": "stable" }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "anchor": { "end": { @@ -342,6 +363,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { @@ -573,6 +597,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { @@ -780,6 +807,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { @@ -1016,6 +1046,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx index 8fb784aa4f..251189e6ca 100644 --- a/apps/docs/document-api/reference/mutations/apply.mdx +++ b/apps/docs/document-api/reference/mutations/apply.mdx @@ -504,6 +504,23 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "ref" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" } ] } @@ -725,7 +742,11 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "properties": { "args": { "additionalProperties": false, - "properties": {}, + "properties": { + "behavior": { + "$ref": "#/$defs/DeleteBehavior" + } + }, "type": "object" }, "id": { @@ -854,6 +875,23 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "ref" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" } ] } @@ -1813,6 +1851,23 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "ref" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" } ] } diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx index 1ce3667a60..1193559c50 100644 --- a/apps/docs/document-api/reference/mutations/preview.mdx +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -490,6 +490,23 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "ref" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" } ] } @@ -711,7 +728,11 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "properties": { "args": { "additionalProperties": false, - "properties": {}, + "properties": { + "behavior": { + "$ref": "#/$defs/DeleteBehavior" + } + }, "type": "object" }, "id": { @@ -840,6 +861,23 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "ref" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" } ] } @@ -1799,6 +1837,23 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "ref" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "target", + "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "by", + "target" + ], + "type": "object" } ] } diff --git a/apps/docs/document-api/reference/query/match.mdx b/apps/docs/document-api/reference/query/match.mdx index 63fc74fd7f..ab0498f536 100644 --- a/apps/docs/document-api/reference/query/match.mdx +++ b/apps/docs/document-api/reference/query/match.mdx @@ -135,7 +135,20 @@ Returns a QueryMatchOutput with the resolved target address and cardinality meta }, "id": "id-001", "matchKind": "text", - "snippet": "...the quick brown fox..." + "snippet": "...the quick brown fox...", + "target": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + } } ], "meta": { @@ -316,6 +329,9 @@ Returns a QueryMatchOutput with the resolved target address and cardinality meta }, "snippet": { "type": "string" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" } }, "required": [ @@ -323,6 +339,7 @@ Returns a QueryMatchOutput with the resolved target address and cardinality meta "handle", "matchKind", "address", + "target", "snippet", "highlightRange", "blocks" diff --git a/apps/docs/document-api/reference/replace.mdx b/apps/docs/document-api/reference/replace.mdx index 61a9966fed..4c443f24f8 100644 --- a/apps/docs/document-api/reference/replace.mdx +++ b/apps/docs/document-api/reference/replace.mdx @@ -1,7 +1,7 @@ --- title: replace sidebarTitle: replace -description: "Replace content at a target position with new content. Accepts two input shapes: legacy string-based (text) or structural SDFragment (content). Structural mode replaces the target range with typed nodes (paragraphs, tables, images, etc.)." +description: Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts an SDAddress, SelectionTarget, or ref plus SDFragment content. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: "Replace content at a target position with new content. Accepts two ## Summary -Replace content at a target position with new content. Accepts two input shapes: legacy string-based (text) or structural SDFragment (content). Structural mode replaces the target range with typed nodes (paragraphs, tables, images, etc.). +Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts an SDAddress, SelectionTarget, or ref plus SDFragment content. - Operation ID: `replace` - API member path: `editor.doc.replace(...)` @@ -22,30 +22,45 @@ Replace content at a target position with new content. Accepts two input shapes: ## Expected result -Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the target range already contains identical content. +Returns an SDMutationReceipt with applied status; receipt reports NO_OP if the target range already contains identical content. ## Input fields +### Variant 1 + | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | TextAddress | yes | TextAddress | -| `target.blockId` | string | yes | | -| `target.kind` | `"text"` | yes | Constant: `"text"` | -| `target.range` | Range | yes | Range | -| `target.range.end` | integer | yes | | -| `target.range.start` | integer | yes | | +| `ref` | string | no | | +| `target` | SelectionTarget | no | SelectionTarget | +| `target.end` | SelectionPoint | no | SelectionPoint | +| `target.kind` | `"selection"` | no | Constant: `"selection"` | +| `target.start` | SelectionPoint | no | SelectionPoint | | `text` | string | yes | | +### Variant 2 + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `content` | object | yes | | +| `nestingPolicy` | object | no | | +| `ref` | string | no | | +| `target` | object \\| TextAddress \\| SelectionTarget | no | One of: object, TextAddress, SelectionTarget | + ### Example request ```json { "target": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 } }, "text": "Hello, world." @@ -54,7 +69,7 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ## Output fields -### Variant 1 (success=true) +### Variant 1 (resolution.selectionTarget.kind="selection") | Field | Type | Required | Description | | --- | --- | --- | --- | @@ -75,6 +90,10 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.requestedTarget.nodeId` | string | no | | | `resolution.requestedTarget.path` | string \\| integer[] | no | | | `resolution.requestedTarget.stability` | enum | no | `"stable"`, `"ephemeral"` | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | object | no | | | `resolution.target.anchor` | object | no | | | `resolution.target.anchor.end` | object | no | | @@ -90,7 +109,7 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.target.stability` | enum | no | `"stable"`, `"ephemeral"` | | `success` | `true` | yes | Constant: `true` | -### Variant 2 (success=false) +### Variant 2 (resolution.selectionTarget.kind="selection") | Field | Type | Required | Description | | --- | --- | --- | --- | @@ -115,6 +134,10 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the | `resolution.requestedTarget.nodeId` | string | no | | | `resolution.requestedTarget.path` | string \\| integer[] | no | | | `resolution.requestedTarget.stability` | enum | no | `"stable"`, `"ephemeral"` | +| `resolution.selectionTarget` | SelectionTarget | no | SelectionTarget | +| `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | +| `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | +| `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | | `resolution.target` | object | no | | | `resolution.target.anchor` | object | no | | | `resolution.target.anchor.end` | object | no | | @@ -154,6 +177,19 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the "nodeId": "node-def456", "stability": "stable" }, + "selectionTarget": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + }, "target": { "anchor": { "end": { @@ -206,20 +242,256 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ```json { - "additionalProperties": false, - "properties": { - "target": { - "$ref": "#/$defs/TextAddress" + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/TargetLocator" + }, + { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + } + ] }, - "text": { - "type": "string" + { + "allOf": [ + { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "type": "object" + }, + "evaluatedRevision": { + "type": "string" + }, + "kind": { + "enum": [ + "content", + "inline", + "annotation", + "section" + ] + }, + "nodeId": { + "type": "string" + }, + "path": { + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array" + }, + "stability": { + "enum": [ + "stable", + "ephemeral" + ] + } + }, + "required": [ + "kind", + "stability" + ], + "type": "object" + }, + { + "$ref": "#/$defs/TextAddress" + }, + { + "$ref": "#/$defs/SelectionTarget" + } + ] + } + }, + "required": [ + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + } + }, + "required": [ + "ref" + ], + "type": "object" + } + ] + }, + { + "additionalProperties": false, + "properties": { + "content": { + "type": "object" + }, + "nestingPolicy": { + "type": "object" + }, + "ref": { + "type": "string" + }, + "target": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "type": "object" + }, + "evaluatedRevision": { + "type": "string" + }, + "kind": { + "enum": [ + "content", + "inline", + "annotation", + "section" + ] + }, + "nodeId": { + "type": "string" + }, + "path": { + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array" + }, + "stability": { + "enum": [ + "stable", + "ephemeral" + ] + } + }, + "required": [ + "kind", + "stability" + ], + "type": "object" + }, + { + "$ref": "#/$defs/TextAddress" + }, + { + "$ref": "#/$defs/SelectionTarget" + } + ] + } + }, + "required": [ + "content" + ], + "type": "object" + } + ] } - }, - "required": [ - "target", - "text" - ], - "type": "object" + ] } ``` @@ -331,6 +603,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { @@ -560,6 +835,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { @@ -767,6 +1045,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { @@ -1001,6 +1282,9 @@ Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the ], "type": "object" }, + "selectionTarget": { + "$ref": "#/$defs/SelectionTarget" + }, "target": { "additionalProperties": false, "properties": { diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.ts index e798b7e8ea..54b79e8480 100644 --- a/packages/document-api/scripts/lib/reference-docs-artifacts.ts +++ b/packages/document-api/scripts/lib/reference-docs-artifacts.ts @@ -221,6 +221,12 @@ function schemaTypeLabel(schema: JsonSchema, $defs: Defs): string { return `enum`; } + // allOf — flatten and derive type from merged schema + if (Array.isArray(schema.allOf)) { + const flat = flattenAllOf(schema, $defs); + return schemaTypeLabel(flat, $defs); + } + // oneOf / anyOf for (const keyword of ['oneOf', 'anyOf'] as const) { const variants = schema[keyword]; @@ -272,6 +278,11 @@ function schemaDescription(schema: JsonSchema, $defs: Defs): string { return (schema.enum as unknown[]).map((v) => `\`${JSON.stringify(v)}\``).join(', '); } + if (Array.isArray(schema.allOf)) { + const flat = flattenAllOf(schema, $defs); + return schemaDescription(flat, $defs); + } + for (const keyword of ['oneOf', 'anyOf'] as const) { const variants = schema[keyword]; if (Array.isArray(variants)) { @@ -308,6 +319,56 @@ function collectConstDiscriminators( return discriminators; } +/** + * If `schema` contains an `allOf` array, merge all members' properties and + * required fields into a single flat object schema. Recursively resolves + * `$ref` pointers inside each member. Returns the original schema unchanged + * when no `allOf` is present. + */ +function flattenAllOf(schema: JsonSchema, $defs: Defs): JsonSchema { + const allOf = schema.allOf; + if (!Array.isArray(allOf) || allOf.length === 0) return schema; + + const mergedProperties: Record = {}; + const mergedRequired = new Set(); + + for (const member of allOf as JsonSchema[]) { + const { resolved } = resolveRef(member, $defs); + // Recursively flatten nested allOf + const flat = flattenAllOf(resolved, $defs); + + if (flat.properties && typeof flat.properties === 'object') { + Object.assign(mergedProperties, flat.properties); + } + if (Array.isArray(flat.required)) { + for (const r of flat.required as string[]) mergedRequired.add(r); + } + + // For oneOf/anyOf TargetLocator-style schemas, extract properties from + // each variant so they appear in the merged field table. + for (const keyword of ['oneOf', 'anyOf'] as const) { + const variants = flat[keyword]; + if (!Array.isArray(variants)) continue; + for (const variant of variants as JsonSchema[]) { + const { resolved: varResolved } = resolveRef(variant, $defs); + if (varResolved.properties && typeof varResolved.properties === 'object') { + // Only merge the properties — requirement is optional since + // these are union alternatives, not all simultaneously required. + Object.assign(mergedProperties, varResolved.properties); + } + } + } + } + + // Preserve any top-level non-allOf keys (e.g. type, description) + const result: JsonSchema = { ...schema, type: 'object', properties: mergedProperties }; + delete result.allOf; + if (mergedRequired.size > 0) { + result.required = [...mergedRequired]; + } + return result; +} + /** * Build field table rows from an object schema's properties. * Recursively flattens nested objects into dot-path rows. @@ -316,10 +377,11 @@ function buildFieldRows(schema: JsonSchema, $defs: Defs, prefix = '', parentRequ if (depth > 8) return []; const { resolved } = resolveRef(schema, $defs); - const properties = resolved.properties as Record | undefined; - if (!properties || resolved.type !== 'object') return []; + const flat = flattenAllOf(resolved, $defs); + const properties = flat.properties as Record | undefined; + if (!properties || flat.type !== 'object') return []; - const requiredSet = new Set(Array.isArray(resolved.required) ? (resolved.required as string[]) : []); + const requiredSet = new Set(Array.isArray(flat.required) ? (flat.required as string[]) : []); const rows: FieldRow[] = []; for (const field of Object.keys(properties).sort()) { @@ -343,9 +405,11 @@ function buildFieldRows(schema: JsonSchema, $defs: Defs, prefix = '', parentRequ /** Build field sections, splitting top-level oneOf/anyOf schemas into explicit variants. */ function buildFieldSections(schema: JsonSchema, $defs: Defs): FieldSection[] { const { resolved } = resolveRef(schema, $defs); + // Flatten allOf first — the merged schema may itself contain oneOf/anyOf. + const flat = flattenAllOf(resolved, $defs); for (const keyword of ['oneOf', 'anyOf'] as const) { - const variants = resolved[keyword]; + const variants = flat[keyword]; if (!Array.isArray(variants) || variants.length === 0) continue; return variants.map((variant, index) => { @@ -364,7 +428,7 @@ function buildFieldSections(schema: JsonSchema, $defs: Defs): FieldSection[] { }); } - return [{ rows: buildFieldRows(resolved, $defs) }]; + return [{ rows: buildFieldRows(flat, $defs) }]; } /** Escape pipe characters inside markdown table cells. */ @@ -535,6 +599,21 @@ function generateExample(schema: JsonSchema, $defs: Defs, fieldName?: string, de return result; } + // allOf — generate per-member and merge. This preserves oneOf/anyOf + // variant selection (first-variant-only) instead of flattening all + // variant properties into a single object. + if (Array.isArray(schema.allOf)) { + const merged: Record = {}; + for (const member of schema.allOf as JsonSchema[]) { + const { resolved } = resolveRef(member, $defs); + const memberExample = generateExample(resolved, $defs, fieldName, depth + 1); + if (typeof memberExample === 'object' && memberExample !== null && !Array.isArray(memberExample)) { + Object.assign(merged, memberExample as Record); + } + } + return merged; + } + // oneOf / anyOf — first variant (non-object union fallback) for (const keyword of ['oneOf', 'anyOf'] as const) { const variants = schema[keyword]; diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 6db6207d09..952e0d4d98 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -420,11 +420,11 @@ export const OPERATION_DEFINITIONS = { replace: { memberPath: 'replace', description: - 'Replace content at a target position with new content. ' + - 'Accepts two input shapes: legacy string-based (text) or structural SDFragment (content). ' + - 'Structural mode replaces the target range with typed nodes (paragraphs, tables, images, etc.).', + 'Replace content at a contiguous document selection. ' + + 'Text path accepts a SelectionTarget or ref plus replacement text. ' + + 'Structural path accepts an SDAddress, SelectionTarget, or ref plus SDFragment content.', expectedResult: - 'Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the target range already contains identical content.', + 'Returns an SDMutationReceipt with applied status; receipt reports NO_OP if the target range already contains identical content.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', @@ -460,16 +460,17 @@ export const OPERATION_DEFINITIONS = { }, delete: { memberPath: 'delete', - description: 'Delete content at a target position.', + description: + 'Delete content at a contiguous document selection. Accepts a SelectionTarget or mutation-ready ref. Supports cross-block deletion and optional block-edge expansion via behavior mode.', expectedResult: - 'Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the target range is already empty.', + 'Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the target range is collapsed or empty.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['NO_OP'], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], }), referenceDocPath: 'delete.mdx', referenceGroup: 'core', diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 0a026e2e19..f7d9dd4069 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1,6 +1,7 @@ import { COMMAND_CATALOG } from './command-catalog.js'; import { CONTRACT_VERSION, JSON_SCHEMA_DIALECT, OPERATION_IDS, type OperationId } from './types.js'; import { NODE_TYPES, BLOCK_NODE_TYPES, DELETABLE_BLOCK_NODE_TYPES, INLINE_NODE_TYPES } from '../types/base.js'; +import { SELECTION_EDGE_NODE_TYPES } from '../types/address.js'; import { INLINE_PROPERTY_REGISTRY, buildInlineRunPatchSchema } from '../format/inline-run-patch.js'; import { INLINE_DIRECTIVES } from '../types/style-policy.types.js'; import { @@ -159,6 +160,49 @@ const SHARED_DEFS: Record = { }, ['kind', 'segments'], ), + + // -- Selection-based targeting -- + SelectionEdgeNodeAddress: objectSchema( + { + kind: { const: 'block' }, + nodeType: { enum: [...SELECTION_EDGE_NODE_TYPES] }, + nodeId: { type: 'string' }, + }, + ['kind', 'nodeType', 'nodeId'], + ), + SelectionPoint: { + oneOf: [ + objectSchema({ kind: { const: 'text' }, blockId: { type: 'string' }, offset: { type: 'integer', minimum: 0 } }, [ + 'kind', + 'blockId', + 'offset', + ]), + objectSchema( + { + kind: { const: 'nodeEdge' }, + node: ref('SelectionEdgeNodeAddress'), + edge: { enum: ['before', 'after'] }, + }, + ['kind', 'node', 'edge'], + ), + ], + } satisfies JsonSchema, + SelectionTarget: objectSchema( + { + kind: { const: 'selection' }, + start: ref('SelectionPoint'), + end: ref('SelectionPoint'), + }, + ['kind', 'start', 'end'], + ), + TargetLocator: { + oneOf: [ + objectSchema({ target: ref('SelectionTarget') }, ['target']), + objectSchema({ ref: { type: 'string' } }, ['ref']), + ], + } satisfies JsonSchema, + DeleteBehavior: { enum: ['selection', 'exact'] } satisfies JsonSchema, + BlockNodeAddress: objectSchema( { kind: { const: 'block' }, @@ -286,6 +330,7 @@ const SHARED_DEFS: Record = { target: ref('TextAddress'), range: ref('TextMutationRange'), text: { type: 'string' }, + selectionTarget: ref('SelectionTarget'), }, ['target', 'range', 'text'], ), @@ -399,6 +444,9 @@ const nodeAddressSchema = ref('NodeAddress'); const commentAddressSchema = ref('CommentAddress'); const trackedChangeAddressSchema = ref('TrackedChangeAddress'); const entityAddressSchema = ref('EntityAddress'); +const selectionTargetSchema = ref('SelectionTarget'); +const targetLocatorSchema = ref('TargetLocator'); +const deleteBehaviorSchema = ref('DeleteBehavior'); const resolvedHandleSchema = ref('ResolvedHandle'); const pageInfoSchema = ref('PageInfo'); const receiptSuccessSchema = ref('ReceiptSuccess'); @@ -683,6 +731,7 @@ const matchContextSchema = objectSchema( snippet: { type: 'string' }, highlightRange: rangeSchema, textRanges: arraySchema(textAddressSchema), + target: selectionTargetSchema, }, ['address', 'snippet', 'highlightRange'], ); @@ -810,6 +859,7 @@ const sdMutationResolutionSchema = objectSchema( { requestedTarget: sdAddressSchema, target: sdAddressSchema, + selectionTarget: selectionTargetSchema, }, ['target'], ); @@ -1509,15 +1559,11 @@ function supportsImplicitTrueValue(operationId: FormatInlineAliasOperationId): b const formatInlineAliasOperationSchemas: Record = Object.fromEntries( INLINE_PROPERTY_REGISTRY.map((entry) => { const operationId = `format.${entry.key}` as FormatInlineAliasOperationId; - const requiredFields = supportsImplicitTrueValue(operationId) ? ['target'] : ['target', 'value']; + const requiredFields = supportsImplicitTrueValue(operationId) ? [] : ['value']; const schema: OperationSchemaSet = { - input: objectSchema( - { - target: textAddressSchema, - value: entry.schema, - }, - requiredFields, - ), + input: { + allOf: [targetLocatorSchema, objectSchema({ value: entry.schema }, requiredFields)], + }, output: textMutationResultSchemaFor(operationId), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor(operationId), @@ -2515,36 +2561,53 @@ const operationSchemas: Record = { failure: sdMutationFailureSchemaFor('insert'), }, replace: { - input: objectSchema( - { - target: textAddressSchema, - text: { type: 'string' }, - }, - ['target', 'text'], - ), + input: { + oneOf: [ + // Text replacement: TargetLocator + text + { + allOf: [targetLocatorSchema, objectSchema({ text: { type: 'string' } }, ['text'])], + }, + // Structural replacement: exactly one of (target | ref) + content + { + allOf: [ + // Require at least one locator — mirrors runtime validation. + { + oneOf: [ + objectSchema({ target: { oneOf: [sdAddressSchema, textAddressSchema, selectionTargetSchema] } }, [ + 'target', + ]), + objectSchema({ ref: { type: 'string' } }, ['ref']), + ], + }, + objectSchema( + { + target: { oneOf: [sdAddressSchema, textAddressSchema, selectionTargetSchema] }, + ref: { type: 'string' }, + content: { type: 'object' }, + nestingPolicy: { type: 'object' }, + }, + ['content'], + ), + ], + }, + ], + }, output: sdMutationResultSchemaFor('replace'), success: sdMutationSuccessSchema, failure: sdMutationFailureSchemaFor('replace'), }, delete: { - input: objectSchema( - { - target: textAddressSchema, - }, - ['target'], - ), + input: { + allOf: [targetLocatorSchema, objectSchema({ behavior: deleteBehaviorSchema })], + }, output: textMutationResultSchemaFor('delete'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('delete'), }, 'format.apply': { - input: objectSchema( - { - target: textAddressSchema, - inline: buildInlineRunPatchSchema(), - }, - ['target', 'inline'], - ), + input: { + allOf: [targetLocatorSchema, objectSchema({ inline: buildInlineRunPatchSchema() }, ['inline'])], + }, output: textMutationResultSchemaFor('format.apply'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('format.apply'), @@ -3864,11 +3927,12 @@ const operationSchemas: Record = { { matchKind: { const: 'text' }, address: nodeAddressSchema, + target: selectionTargetSchema, snippet: { type: 'string' }, highlightRange: rangeSchema, blocks: { type: 'array', items: matchBlockSchema, minItems: 1 }, }, - ['matchKind', 'address', 'snippet', 'highlightRange', 'blocks'], + ['matchKind', 'address', 'target', 'snippet', 'highlightRange', 'blocks'], ); // Node match item: id + handle + address + empty blocks @@ -3912,7 +3976,15 @@ const operationSchemas: Record = { ['by', 'ref'], ); - const stepWhereSchema: JsonSchema = { oneOf: [selectWhereSchema, refWhereSchema] }; + const targetWhereSchema = objectSchema( + { + by: { const: 'target', type: 'string' }, + target: selectionTargetSchema, + }, + ['by', 'target'], + ); + + const stepWhereSchema: JsonSchema = { oneOf: [selectWhereSchema, refWhereSchema, targetWhereSchema] }; // Insert-only where (no 'all' require, no ref) const insertWhereSchema = objectSchema( @@ -4026,7 +4098,7 @@ const operationSchemas: Record = { id: { type: 'string' }, op: { const: 'text.delete', type: 'string' }, where: stepWhereSchema, - args: objectSchema({}), + args: objectSchema({ behavior: deleteBehaviorSchema }), }, ['id', 'op', 'where', 'args'], ); diff --git a/packages/document-api/src/delete/delete.ts b/packages/document-api/src/delete/delete.ts index 76ecef5bd4..2ab45f2014 100644 --- a/packages/document-api/src/delete/delete.ts +++ b/packages/document-api/src/delete/delete.ts @@ -1,23 +1,42 @@ -import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; -import type { TextAddress, TextMutationReceipt } from '../types/index.js'; +/** + * Delete operation — removes content at a contiguous document selection. + * + * Accepts either an explicit `SelectionTarget` or a mutation-ready `ref` + * string from discovery APIs (`query.match`, `find`). + */ + +import type { SelectionTarget, DeleteBehavior } from '../types/address.js'; +import type { TextMutationReceipt } from '../types/receipt.js'; +import type { MutationOptions } from '../types/mutation-plan.types.js'; +import type { SelectionMutationAdapter } from '../selection-mutation.js'; import { DocumentApiValidationError } from '../errors.js'; -import { isRecord, isTextAddress, assertNoUnknownFields } from '../validation-primitives.js'; +import { isRecord, assertNoUnknownFields } from '../validation-primitives.js'; +import { isSelectionTarget } from '../validation/selection-target-validator.js'; + +// --------------------------------------------------------------------------- +// Public input type +// --------------------------------------------------------------------------- export interface DeleteInput { - target: TextAddress; + /** Explicit selection target. Exactly one of `target` or `ref` is required. */ + target?: SelectionTarget; + /** Mutation-ready ref from `query.match` or `find`. */ + ref?: string; + /** + * Delete behavior mode. + * - `'selection'` (default): expand to block edges when boundary blocks are fully covered. + * - `'exact'`: delete only the exact resolved range. + */ + behavior?: DeleteBehavior; } -const DELETE_INPUT_ALLOWED_KEYS = new Set(['target']); +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +const DELETE_INPUT_ALLOWED_KEYS = new Set(['target', 'ref', 'behavior']); +const VALID_BEHAVIORS: ReadonlySet = new Set(['selection', 'exact']); -/** - * Validates DeleteInput and throws DocumentApiValidationError on violations. - * - * Validation order: - * 0. Input shape guard - * 1. Unknown field rejection - * 2. Target is required - * 3. Target type check - */ function validateDeleteInput(input: unknown): asserts input is DeleteInput { if (!isRecord(input)) { throw new DocumentApiValidationError('INVALID_TARGET', 'Delete input must be a non-null object.'); @@ -25,28 +44,67 @@ function validateDeleteInput(input: unknown): asserts input is DeleteInput { assertNoUnknownFields(input, DELETE_INPUT_ALLOWED_KEYS, 'delete'); - const { target } = input; + const { target, ref, behavior } = input; + + // Exactly one of target or ref + const hasTarget = target !== undefined; + const hasRef = ref !== undefined; - // Target is required - if (target === undefined) { - throw new DocumentApiValidationError('INVALID_TARGET', 'Delete requires a target.'); + if (hasTarget && hasRef) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + 'Delete input must provide either "target" or "ref", not both.', + { fields: ['target', 'ref'] }, + ); } - // Type check - if (!isTextAddress(target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + if (!hasTarget && !hasRef) { + throw new DocumentApiValidationError('INVALID_INPUT', 'Delete input must provide either "target" or "ref".', { + fields: ['target', 'ref'], + }); + } + + if (hasTarget && !isSelectionTarget(target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a SelectionTarget object.', { field: 'target', value: target, }); } + + if (hasRef && typeof ref !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', 'ref must be a string.', { + field: 'ref', + value: ref, + }); + } + + if (behavior !== undefined && !VALID_BEHAVIORS.has(behavior as string)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `behavior must be "selection" or "exact", got "${String(behavior)}".`, + { field: 'behavior', value: behavior }, + ); + } } +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + export function executeDelete( - adapter: WriteAdapter, + adapter: SelectionMutationAdapter, input: DeleteInput, options?: MutationOptions, ): TextMutationReceipt { validateDeleteInput(input); - return executeWrite(adapter, { kind: 'delete', target: input.target, text: '' }, options); + return adapter.execute( + { + kind: 'delete', + target: input.target, + ref: input.ref, + behavior: input.behavior ?? 'selection', + }, + options, + ); } diff --git a/packages/document-api/src/format/format.test.ts b/packages/document-api/src/format/format.test.ts index 505c417229..8e16460e02 100644 --- a/packages/document-api/src/format/format.test.ts +++ b/packages/document-api/src/format/format.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it, vi, assertType } from 'vitest'; -import type { FormatAdapter, FormatInlineAliasInput, StyleApplyInput } from './format.js'; +import type { FormatInlineAliasInput, StyleApplyInput } from './format.js'; import { executeStyleApply, executeInlineAlias } from './format.js'; import { DocumentApiValidationError } from '../errors.js'; import type { TextMutationReceipt } from '../types/index.js'; +import type { SelectionMutationAdapter } from '../selection-mutation.js'; +import type { SelectionTarget } from '../types/address.js'; -const TARGET = { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 5 } }; +const TARGET: SelectionTarget = { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 0 }, + end: { kind: 'text', blockId: 'p1', offset: 5 }, +}; function makeReceipt(): TextMutationReceipt { return { @@ -19,9 +25,9 @@ function makeReceipt(): TextMutationReceipt { }; } -function makeAdapter(): FormatAdapter & Record> { +function makeAdapter(): SelectionMutationAdapter & Record> { return { - apply: vi.fn(() => makeReceipt()), + execute: vi.fn(() => makeReceipt()), }; } @@ -42,13 +48,13 @@ describe('executeStyleApply validation', () => { it('rejects missing target', () => { const adapter = makeAdapter(); const input = { inline: { bold: true } }; - expect(() => executeStyleApply(adapter, input as any)).toThrow('requires a target'); + expect(() => executeStyleApply(adapter, input as any)).toThrow('either "target" or "ref"'); }); it('rejects invalid target', () => { const adapter = makeAdapter(); const input = { target: 'not-an-address', inline: { bold: true } }; - expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); + expect(() => executeStyleApply(adapter, input as any)).toThrow('SelectionTarget'); }); it('accepts valid target', () => { @@ -99,7 +105,10 @@ describe('executeStyleApply validation', () => { const input: StyleApplyInput = { target: TARGET, inline: { bold: null, italic: false } }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); - expect(adapter.apply).toHaveBeenCalledWith(input, expect.objectContaining({ changeMode: 'direct' })); + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { bold: null, italic: false } }, + expect.objectContaining({ changeMode: 'direct' }), + ); }); it('accepts numeric and object inline properties in one call', () => { @@ -119,7 +128,10 @@ describe('executeStyleApply validation', () => { const adapter = makeAdapter(); const input: StyleApplyInput = { target: TARGET, inline: { color: '00AA00' } }; executeStyleApply(adapter, input, { changeMode: 'tracked', dryRun: true }); - expect(adapter.apply).toHaveBeenCalledWith(input, { changeMode: 'tracked', dryRun: true }); + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { color: '00AA00' } }, + { changeMode: 'tracked', dryRun: true }, + ); }); }); @@ -130,8 +142,8 @@ describe('executeInlineAlias', () => { it('format.bold accepts omitted value (defaults to true)', () => { const adapter = makeAdapter(); executeInlineAlias(adapter, 'bold', { target: TARGET }); - expect(adapter.apply).toHaveBeenCalledWith( - { target: TARGET, inline: { bold: true } }, + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { bold: true } }, expect.objectContaining({ changeMode: 'direct' }), ); }); @@ -139,8 +151,8 @@ describe('executeInlineAlias', () => { it('format.underline accepts omitted value (defaults to true)', () => { const adapter = makeAdapter(); executeInlineAlias(adapter, 'underline', { target: TARGET }); - expect(adapter.apply).toHaveBeenCalledWith( - { target: TARGET, inline: { underline: true } }, + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { underline: true } }, expect.objectContaining({ changeMode: 'direct' }), ); }); @@ -169,8 +181,8 @@ describe('executeInlineAlias', () => { it('format.color accepts explicit value', () => { const adapter = makeAdapter(); executeInlineAlias(adapter, 'color', { target: TARGET, value: 'FF0000' }); - expect(adapter.apply).toHaveBeenCalledWith( - { target: TARGET, inline: { color: 'FF0000' } }, + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { color: 'FF0000' } }, expect.objectContaining({ changeMode: 'direct' }), ); }); @@ -180,8 +192,8 @@ describe('executeInlineAlias: format.caps', () => { it('format.caps accepts omitted value (defaults to true)', () => { const adapter = makeAdapter(); executeInlineAlias(adapter, 'caps', { target: TARGET }); - expect(adapter.apply).toHaveBeenCalledWith( - { target: TARGET, inline: { caps: true } }, + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { caps: true } }, expect.objectContaining({ changeMode: 'direct' }), ); }); @@ -189,8 +201,8 @@ describe('executeInlineAlias: format.caps', () => { it('format.caps accepts explicit false', () => { const adapter = makeAdapter(); executeInlineAlias(adapter, 'caps', { target: TARGET, value: false }); - expect(adapter.apply).toHaveBeenCalledWith( - { target: TARGET, inline: { caps: false } }, + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { caps: false } }, expect.objectContaining({ changeMode: 'direct' }), ); }); @@ -198,8 +210,8 @@ describe('executeInlineAlias: format.caps', () => { it('format.caps accepts null to clear', () => { const adapter = makeAdapter(); executeInlineAlias(adapter, 'caps', { target: TARGET, value: null }); - expect(adapter.apply).toHaveBeenCalledWith( - { target: TARGET, inline: { caps: null } }, + expect(adapter.execute).toHaveBeenCalledWith( + { kind: 'format', target: TARGET, ref: undefined, inline: { caps: null } }, expect.objectContaining({ changeMode: 'direct' }), ); }); diff --git a/packages/document-api/src/format/format.ts b/packages/document-api/src/format/format.ts index a716e991fb..33a559c5c8 100644 --- a/packages/document-api/src/format/format.ts +++ b/packages/document-api/src/format/format.ts @@ -1,10 +1,22 @@ -import { normalizeMutationOptions, type MutationOptions } from '../write/write.js'; -import type { TextAddress, TextMutationReceipt } from '../types/index.js'; +/** + * Format operations — inline style application on contiguous document selections. + * + * All format operations now accept `SelectionTarget` or `ref` instead of `TextAddress`. + * They route through the `SelectionMutationAdapter` (backed by the plan engine). + */ + +import type { MutationOptions } from '../types/mutation-plan.types.js'; +import { normalizeMutationOptions } from '../write/write.js'; +import type { SelectionTarget } from '../types/address.js'; +import type { TextMutationReceipt } from '../types/receipt.js'; +import type { SelectionMutationAdapter } from '../selection-mutation.js'; import { DocumentApiValidationError } from '../errors.js'; -import { isRecord, isTextAddress, assertNoUnknownFields } from '../validation-primitives.js'; +import { isRecord, assertNoUnknownFields } from '../validation-primitives.js'; +import { isSelectionTarget } from '../validation/selection-target-validator.js'; import type { InlineRunPatch, InlineRunPatchKey } from './inline-run-patch.js'; import { INLINE_PROPERTY_BY_KEY, validateInlineRunPatch } from './inline-run-patch.js'; +// --------------------------------------------------------------------------- // Input types // --------------------------------------------------------------------------- @@ -19,7 +31,8 @@ export type FormatUnderlineInput = FormatInlineAliasInput<'underline'>; /** Input payload for `format.strikethrough`. */ export interface FormatStrikethroughInput { - target: TextAddress; + target?: SelectionTarget; + ref?: string; } /** @@ -37,22 +50,19 @@ type ImplicitTrueKey = * * `value` is optional only for boolean-like keys (including `underline`), where * omission defaults to `true` for ergonomic "turn on" calls. - * For all other keys the caller must supply a value. */ export type FormatInlineAliasInput = K extends ImplicitTrueKey - ? { target: TextAddress; value?: InlineRunPatch[K] } - : { target: TextAddress; value: InlineRunPatch[K] }; + ? { target?: SelectionTarget; ref?: string; value?: InlineRunPatch[K] } + : { target?: SelectionTarget; ref?: string; value: InlineRunPatch[K] }; /** * Input payload for `format.apply`. * - * `inline` uses explicit patch semantics: - * - omitted key: unchanged - * - concrete value: set - * - `null`: clear + * Accepts either `target` (SelectionTarget) or `ref` (string) — exactly one required. */ export interface StyleApplyInput { - target: TextAddress; + target?: SelectionTarget; + ref?: string; inline: InlineRunPatch; } @@ -60,10 +70,11 @@ export interface StyleApplyInput { export type StyleApplyOptions = MutationOptions; // --------------------------------------------------------------------------- -// Adapter interface +// Legacy FormatAdapter — kept temporarily for inline aliases that still +// route through the old path. Will be fully retired once all aliases migrate. // --------------------------------------------------------------------------- -/** Engine-specific adapter for format operations. */ +/** @deprecated Use SelectionMutationAdapter instead. Kept for inline-alias compatibility. */ export interface FormatAdapter { apply(input: StyleApplyInput, options?: MutationOptions): TextMutationReceipt; } @@ -84,29 +95,56 @@ export interface FormatApi extends FormatInlineAliasApi { } // --------------------------------------------------------------------------- -// format.apply — validation and execution +// Shared target validation // --------------------------------------------------------------------------- -const STYLE_APPLY_INPUT_ALLOWED_KEYS = new Set(['target', 'inline']); +function validateTargetLocator(input: Record, operation: string): void { + const hasTarget = input.target !== undefined; + const hasRef = input.ref !== undefined; -function validateStyleApplyInput(input: unknown): asserts input is StyleApplyInput { - if (!isRecord(input)) { - throw new DocumentApiValidationError('INVALID_INPUT', 'format.apply input must be a non-null object.'); + if (hasTarget && hasRef) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${operation} input must provide either "target" or "ref", not both.`, + { fields: ['target', 'ref'] }, + ); } - assertNoUnknownFields(input, STYLE_APPLY_INPUT_ALLOWED_KEYS, 'format.apply'); - - if (input.target === undefined) { - throw new DocumentApiValidationError('INVALID_TARGET', 'format.apply requires a target.'); + if (!hasTarget && !hasRef) { + throw new DocumentApiValidationError('INVALID_INPUT', `${operation} input must provide either "target" or "ref".`, { + fields: ['target', 'ref'], + }); } - if (!isTextAddress(input.target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + if (hasTarget && !isSelectionTarget(input.target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a SelectionTarget object.', { field: 'target', value: input.target, }); } + if (hasRef && typeof input.ref !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', 'ref must be a string.', { + field: 'ref', + value: input.ref, + }); + } +} + +// --------------------------------------------------------------------------- +// format.apply — validation and execution +// --------------------------------------------------------------------------- + +const STYLE_APPLY_INPUT_ALLOWED_KEYS = new Set(['target', 'ref', 'inline']); + +function validateStyleApplyInput(input: unknown): asserts input is StyleApplyInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.apply input must be a non-null object.'); + } + + assertNoUnknownFields(input, STYLE_APPLY_INPUT_ALLOWED_KEYS, 'format.apply'); + validateTargetLocator(input, 'format.apply'); + if (input.inline === undefined || input.inline === null) { throw new DocumentApiValidationError('INVALID_INPUT', 'format.apply requires an inline object.'); } @@ -115,24 +153,30 @@ function validateStyleApplyInput(input: unknown): asserts input is StyleApplyInp } /** - * Executes `format.apply` using the provided adapter. - * - * Validates the target and inline patch payload, then delegates to adapter `apply`. + * Executes `format.apply` via the selection mutation adapter (plan engine). */ export function executeStyleApply( - adapter: FormatAdapter, + adapter: SelectionMutationAdapter, input: StyleApplyInput, options?: MutationOptions, ): TextMutationReceipt { validateStyleApplyInput(input); - return adapter.apply(input, normalizeMutationOptions(options)); + return adapter.execute( + { + kind: 'format', + target: input.target, + ref: input.ref, + inline: input.inline, + }, + normalizeMutationOptions(options), + ); } // --------------------------------------------------------------------------- // format. aliases — normalize to format.apply payloads // --------------------------------------------------------------------------- -const INLINE_ALIAS_INPUT_ALLOWED_KEYS = new Set(['target', 'value']); +const INLINE_ALIAS_INPUT_ALLOWED_KEYS = new Set(['target', 'ref', 'value']); function acceptsImplicitTrue(key: InlineRunPatchKey): boolean { return INLINE_PROPERTY_BY_KEY[key].type === 'boolean' || key === 'underline'; @@ -154,12 +198,9 @@ function validateInlineAliasInput( input: unknown, ): asserts input is FormatInlineAliasInput { const operation = `format.${key}`; - // Preserve historical input semantics for direct aliases: - // - null / primitive input behaves like "{}" and fails with missing target. - // - unknown top-level fields are reported before target validation. const candidate = isRecord(input) ? input : {}; assertNoUnknownFields(candidate, INLINE_ALIAS_INPUT_ALLOWED_KEYS, operation); - validateTarget(candidate, operation); + validateTargetLocator(candidate, operation); } /** @@ -167,35 +208,22 @@ function validateInlineAliasInput( * into a single-key `format.apply` payload. */ export function executeInlineAlias( - adapter: FormatAdapter, + adapter: SelectionMutationAdapter, key: K, input: FormatInlineAliasInput, options?: MutationOptions, ): TextMutationReceipt { validateInlineAliasInput(key, input); - // `input.value` is typed as required or optional depending on K; at runtime - // `normalizeInlineAliasValue` handles both branches uniformly. const value = normalizeInlineAliasValue(key, (input as { value?: InlineRunPatch[K] }).value); const inline = { [key]: value } as InlineRunPatch; validateInlineRunPatch(inline); - return adapter.apply({ target: input.target, inline }, normalizeMutationOptions(options)); -} - -// --------------------------------------------------------------------------- -// Shared validation: target field -// --------------------------------------------------------------------------- - -function validateTarget(input: unknown, operation: string): asserts input is { target: TextAddress } { - if (!isRecord(input)) { - throw new DocumentApiValidationError('INVALID_INPUT', `${operation} input must be a non-null object.`); - } - if (input.target === undefined) { - throw new DocumentApiValidationError('INVALID_TARGET', `${operation} requires a target.`); - } - if (!isTextAddress(input.target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { - field: 'target', - value: input.target, - }); - } + return adapter.execute( + { + kind: 'format', + target: input.target, + ref: input.ref, + inline, + }, + normalizeMutationOptions(options), + ); } diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 4417d48b73..21ddd305ce 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -6,7 +6,7 @@ import type { CommentsDeleteInput, GetCommentInput, } from './comments/comments.js'; -import type { FormatAdapter } from './format/format.js'; +import type { SelectionMutationAdapter } from './selection-mutation.js'; import type { FindAdapter } from './find/find.js'; import type { GetNodeAdapter } from './get-node/get-node.js'; import type { GetAdapter } from './get/get.js'; @@ -126,20 +126,16 @@ function makeWriteAdapter(): WriteAdapter { }; } -function makeFormatReceipt() { +function makeSelectionMutationAdapter(): SelectionMutationAdapter { return { - success: true as const, - resolution: { - target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, - range: { from: 1, to: 3 }, - text: 'Hi', - }, - }; -} - -function makeFormatAdapter(): FormatAdapter { - return { - apply: vi.fn(() => makeFormatReceipt()), + execute: vi.fn(() => ({ + success: true as const, + resolution: { + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, + range: { from: 1, to: 3 }, + text: 'Hi', + }, + })), }; } @@ -381,7 +377,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -404,7 +400,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -426,7 +422,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -448,7 +444,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -470,7 +466,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -495,7 +491,7 @@ describe('createDocumentApi', () => { info: infoAdpt, comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -518,7 +514,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: commentsAdpt, write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -544,7 +540,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: commentsAdpt, write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -567,7 +563,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: commentsAdpt, write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -596,6 +592,7 @@ describe('createDocumentApi', () => { it('delegates write operations through the shared write adapter', () => { const writeAdpt = makeWriteAdapter(); + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -604,42 +601,45 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const insertTarget = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const selectionTarget = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; api.insert({ value: 'Hi' }); - api.insert({ target, value: 'Yo' }); - api.replace({ target, text: 'Hello' }, { changeMode: 'tracked' }); - api.delete({ target }); + api.insert({ target: insertTarget, value: 'Yo' }); + api.replace({ target: selectionTarget, text: 'Hello' }, { changeMode: 'tracked' }); + api.delete({ target: selectionTarget }); expect(writeAdpt.write).toHaveBeenNthCalledWith( 1, - { kind: 'insert', text: 'Hi' }, // write request keeps `text` (internal protocol) + { kind: 'insert', text: 'Hi' }, { changeMode: 'direct', dryRun: false }, ); expect(writeAdpt.write).toHaveBeenNthCalledWith( 2, - { kind: 'insert', target, text: 'Yo' }, // write request keeps `text` (internal protocol) + { kind: 'insert', target: insertTarget, text: 'Yo' }, { changeMode: 'direct', dryRun: false }, ); - expect(writeAdpt.write).toHaveBeenNthCalledWith( - 3, - { kind: 'replace', target, text: 'Hello' }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'replace', target: selectionTarget, ref: undefined, text: 'Hello' }, { changeMode: 'tracked', dryRun: false }, ); - expect(writeAdpt.write).toHaveBeenNthCalledWith( - 4, - { kind: 'delete', target, text: '' }, - { changeMode: 'direct', dryRun: false }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'delete', target: selectionTarget, ref: undefined, behavior: 'selection' }, + undefined, ); }); - it('delegates format.bold to adapter.apply with inline.bold', () => { - const formatAdpt = makeFormatAdapter(); + it('delegates format.bold to selectionMutation.execute with inline.bold', () => { + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -648,22 +648,26 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: formatAdpt, + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; api.format.bold({ target }, { changeMode: 'tracked' }); - expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { bold: true } }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { bold: true } }, { changeMode: 'tracked', dryRun: false }, ); }); - it('delegates format.italic to adapter.apply with inline.italic', () => { - const formatAdpt = makeFormatAdapter(); + it('delegates format.italic to selectionMutation.execute with inline.italic', () => { + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -672,22 +676,26 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: formatAdpt, + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; api.format.italic({ target }, { changeMode: 'direct' }); - expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { italic: true } }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { italic: true } }, { changeMode: 'direct', dryRun: false }, ); }); - it('delegates format.underline to adapter.apply with inline.underline', () => { - const formatAdpt = makeFormatAdapter(); + it('delegates format.underline to selectionMutation.execute with inline.underline', () => { + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -696,22 +704,26 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: formatAdpt, + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; api.format.underline({ target }, { changeMode: 'direct' }); - expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { underline: true } }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { underline: true } }, { changeMode: 'direct', dryRun: false }, ); }); - it('delegates format.strikethrough to adapter.apply with inline.strike', () => { - const formatAdpt = makeFormatAdapter(); + it('delegates format.strikethrough to selectionMutation.execute with inline.strike', () => { + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -720,22 +732,26 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: formatAdpt, + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; api.format.strikethrough({ target }, { changeMode: 'tracked' }); - expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { strike: true } }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { strike: true } }, { changeMode: 'tracked', dryRun: false }, ); }); - it('delegates format.fontFamily to adapter.apply with inline.fontFamily', () => { - const formatAdpt = makeFormatAdapter(); + it('delegates format.fontFamily to selectionMutation.execute with inline.fontFamily', () => { + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -744,16 +760,20 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: formatAdpt, + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; api.format.fontFamily({ target, value: 'Arial' }); - expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { fontFamily: 'Arial' } }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { fontFamily: 'Arial' } }, { changeMode: 'direct', dryRun: false }, ); }); @@ -768,7 +788,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: trackAdpt, create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -793,7 +813,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: trackAdpt, create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -824,7 +844,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -853,7 +873,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -879,7 +899,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -964,7 +984,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: createAdpt, lists: makeListsAdapter(), @@ -998,7 +1018,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: createAdpt, lists: makeListsAdapter(), @@ -1034,7 +1054,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: listsAdpt, @@ -1086,7 +1106,7 @@ describe('createDocumentApi', () => { capabilities: capAdpt, comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1110,7 +1130,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1245,7 +1265,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1269,7 +1289,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1296,7 +1316,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1319,7 +1339,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1342,7 +1362,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1364,7 +1384,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1424,7 +1444,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1473,7 +1493,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1487,7 +1507,6 @@ describe('createDocumentApi', () => { } catch (err: unknown) { const e = err as { name: string; code: string; message: string }; expect(e.name).toBe('DocumentApiValidationError'); - expect(e.code).toBe('INVALID_TARGET'); if (messageMatch) { if (typeof messageMatch === 'string') { expect(e.message).toContain(messageMatch); @@ -1498,18 +1517,27 @@ describe('createDocumentApi', () => { } } + const SELECTION_TARGET = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + // -- Truth table: valid cases -- it('accepts canonical target', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - const result = api.replace({ target, text: 'hello' }); + const result = api.replace({ target: SELECTION_TARGET, text: 'hello' }); expect(result.success).toBe(true); }); it('allows collapsed range (start === end) through pre-apply', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 3, end: 3 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 3 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 3 }, + }; const result = api.replace({ target, text: 'hello' }); expect(result.success).toBe(true); }); @@ -1518,14 +1546,14 @@ describe('createDocumentApi', () => { it('rejects no target at all', () => { const api = makeApi(); - expectValidationError(() => api.replace({ text: 'hello' } as any), 'Replace requires a target'); + expectValidationError(() => api.replace({ text: 'hello' } as any), 'requires a target or ref'); }); it('rejects malformed target', () => { const api = makeApi(); expectValidationError( () => api.replace({ target: { kind: 'text', blockId: 'p1' }, text: 'hello' } as any), - 'target must be a text address object', + 'SelectionTarget', ); }); @@ -1533,8 +1561,7 @@ describe('createDocumentApi', () => { it('rejects non-string text', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - expectValidationError(() => api.replace({ target, text: 42 } as any), 'text must be a string'); + expectValidationError(() => api.replace({ target: SELECTION_TARGET, text: 42 } as any), 'text must be a string'); }); // -- Input shape -- @@ -1546,9 +1573,8 @@ describe('createDocumentApi', () => { it('rejects unknown fields', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; expectValidationError( - () => api.replace({ target, text: 'hi', block_id: 'x' } as any), + () => api.replace({ target: SELECTION_TARGET, text: 'hi', block_id: 'x' } as any), 'Unknown field "block_id"', ); }); @@ -1570,14 +1596,13 @@ describe('createDocumentApi', () => { expect.fail('Expected error'); } catch (err: unknown) { expect((err as Error).constructor.name).toBe('DocumentApiValidationError'); - expect((err as { code: string }).code).toBe('INVALID_TARGET'); } }); // -- Canonical payload parity -- it('sends same adapter request for replace({ target, text }) as before', () => { - const writeAdpt = makeWriteAdapter(); + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -1586,17 +1611,16 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), - write: writeAdpt, - format: makeFormatAdapter(), + write: makeWriteAdapter(), + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - api.replace({ target, text: 'Hello' }); - expect(writeAdpt.write).toHaveBeenCalledWith( - { kind: 'replace', target, text: 'Hello' }, + api.replace({ target: SELECTION_TARGET, text: 'Hello' }); + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'replace', target: SELECTION_TARGET, ref: undefined, text: 'Hello' }, { changeMode: 'direct', dryRun: false }, ); }); @@ -1605,16 +1629,14 @@ describe('createDocumentApi', () => { it('rejects replace with both text and content', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - expect(() => api.replace({ target, text: 'hi', content: { type: 'paragraph' } } as any)).toThrow( - /either "text".*or "content".*not both/, - ); + expect(() => + api.replace({ target: SELECTION_TARGET, text: 'hi', content: { type: 'paragraph' } } as any), + ).toThrow(/either "text".*or "content".*not both/); }); it('rejects replace with neither text nor content', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - expect(() => api.replace({ target } as any)).toThrow(/either "text".*or "content"/); + expect(() => api.replace({ target: SELECTION_TARGET } as any)).toThrow(/either "text".*or "content"/); }); it('routes structural content replace to replaceStructured', () => { @@ -1628,29 +1650,29 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: writeAdpt, - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - api.replace({ target, content: { type: 'paragraph', content: [{ type: 'text', text: 'new' }] } }); + const sdTarget = { kind: 'content' as const, stability: 'stable' as const, nodeId: 'p1' }; + api.replace({ target: sdTarget, content: { type: 'paragraph', content: [{ type: 'text', text: 'new' }] } }); expect(writeAdpt.replaceStructured).toHaveBeenCalledTimes(1); expect(writeAdpt.write).not.toHaveBeenCalled(); }); it('rejects structural replace with empty fragment', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - expect(() => api.replace({ target, content: [] } as any)).toThrow(/at least one node/); + const sdTarget = { kind: 'content' as const, stability: 'stable' as const, nodeId: 'p1' }; + expect(() => api.replace({ target: sdTarget, content: [] } as any)).toThrow(/at least one node/); }); it('rejects structural replace with invalid nestingPolicy.tables', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + const sdTarget = { kind: 'content' as const, stability: 'stable' as const, nodeId: 'p1' }; expect(() => - api.replace({ target, content: { type: 'paragraph' }, nestingPolicy: { tables: 'yes' } } as any), + api.replace({ target: sdTarget, content: { type: 'paragraph' }, nestingPolicy: { tables: 'yes' } } as any), ).toThrow(/nestingPolicy\.tables must be one of/); }); }); @@ -1666,7 +1688,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1680,7 +1702,6 @@ describe('createDocumentApi', () => { } catch (err: unknown) { const e = err as { name: string; code: string; message: string }; expect(e.name).toBe('DocumentApiValidationError'); - expect(e.code).toBe('INVALID_TARGET'); if (messageMatch) { if (typeof messageMatch === 'string') { expect(e.message).toContain(messageMatch); @@ -1691,18 +1712,27 @@ describe('createDocumentApi', () => { } } + const SELECTION_TARGET = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + // -- Truth table: valid cases -- it('accepts canonical target', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - const result = api.delete({ target }); + const result = api.delete({ target: SELECTION_TARGET }); expect(result.success).toBe(true); }); it('allows collapsed range (start === end) through pre-apply', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 3, end: 3 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 3 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 3 }, + }; const result = api.delete({ target }); expect(result.success).toBe(true); }); @@ -1711,15 +1741,12 @@ describe('createDocumentApi', () => { it('rejects no target at all', () => { const api = makeApi(); - expectValidationError(() => api.delete({} as any), 'Delete requires a target'); + expectValidationError(() => api.delete({} as any), 'Delete input must provide either "target" or "ref"'); }); it('rejects malformed target', () => { const api = makeApi(); - expectValidationError( - () => api.delete({ target: { kind: 'text', blockId: 'p1' } } as any), - 'target must be a text address object', - ); + expectValidationError(() => api.delete({ target: { kind: 'text', blockId: 'p1' } } as any), 'SelectionTarget'); }); // -- Input shape -- @@ -1731,8 +1758,7 @@ describe('createDocumentApi', () => { it('rejects unknown fields', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - expectValidationError(() => api.delete({ target, offset: 3 } as any), 'Unknown field "offset"'); + expectValidationError(() => api.delete({ target: SELECTION_TARGET, offset: 3 } as any), 'Unknown field "offset"'); }); it('rejects flat blockId as unknown field', () => { @@ -1743,7 +1769,7 @@ describe('createDocumentApi', () => { // -- Canonical payload parity -- it('sends same adapter request for delete({ target }) as before', () => { - const writeAdpt = makeWriteAdapter(); + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -1752,18 +1778,17 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), - write: writeAdpt, - format: makeFormatAdapter(), + write: makeWriteAdapter(), + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - api.delete({ target }); - expect(writeAdpt.write).toHaveBeenCalledWith( - { kind: 'delete', target, text: '' }, - { changeMode: 'direct', dryRun: false }, + api.delete({ target: SELECTION_TARGET }); + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'delete', target: SELECTION_TARGET, ref: undefined, behavior: 'selection' }, + undefined, ); }); }); @@ -1779,7 +1804,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -1793,7 +1818,6 @@ describe('createDocumentApi', () => { } catch (err: unknown) { const e = err as { name: string; code: string; message: string }; expect(e.name).toBe('DocumentApiValidationError'); - expect(e.code).toBe('INVALID_TARGET'); if (messageMatch) { if (typeof messageMatch === 'string') { expect(e.message).toContain(messageMatch); @@ -1804,6 +1828,12 @@ describe('createDocumentApi', () => { } } + const SELECTION_TARGET = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + const FORMAT_METHODS = ['bold', 'italic', 'underline', 'strikethrough'] as const; for (const method of FORMAT_METHODS) { @@ -1812,14 +1842,17 @@ describe('createDocumentApi', () => { it('accepts canonical target', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - const result = api.format[method]({ target }); + const result = api.format[method]({ target: SELECTION_TARGET }); expect(result.success).toBe(true); }); it('allows collapsed range (start === end) through pre-apply', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 3, end: 3 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 3 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 3 }, + }; const result = api.format[method]({ target }); expect(result.success).toBe(true); }); @@ -1828,14 +1861,14 @@ describe('createDocumentApi', () => { it('rejects no target at all', () => { const api = makeApi(); - expectValidationError(() => api.format[method]({} as any), 'requires a target'); + expectValidationError(() => api.format[method]({} as any), 'either "target" or "ref"'); }); it('rejects malformed target', () => { const api = makeApi(); expectValidationError( () => api.format[method]({ target: { kind: 'text', blockId: 'p1' } } as any), - 'target must be a text address object', + 'SelectionTarget', ); }); @@ -1843,15 +1876,17 @@ describe('createDocumentApi', () => { it('rejects null input', () => { const api = makeApi(); - // null spreads to {}, so the merged object { inline: {...} } passes shape + // null spreads to {}, so the merged object passes shape // checks but fails the locator requirement - expectValidationError(() => api.format[method](null as any), 'requires a target'); + expectValidationError(() => api.format[method](null as any), 'either "target" or "ref"'); }); it('rejects unknown fields', () => { const api = makeApi(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; - expectValidationError(() => api.format[method]({ target, offset: 3 } as any), 'Unknown field "offset"'); + expectValidationError( + () => api.format[method]({ target: SELECTION_TARGET, offset: 3 } as any), + 'Unknown field "offset"', + ); }); it('rejects flat blockId as unknown field', () => { @@ -1866,8 +1901,8 @@ describe('createDocumentApi', () => { // -- Canonical payload parity -- - it('passes canonical target through to adapter.apply with inline', () => { - const formatAdpt = makeFormatAdapter(); + it('passes canonical target through to adapter.execute with inline', () => { + const selectionAdpt = makeSelectionMutationAdapter(); const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -1877,16 +1912,20 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: formatAdpt, + selectionMutation: selectionAdpt, trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), }); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; api.format.bold({ target }); - expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { bold: true } }, + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { bold: true } }, { changeMode: 'direct', dryRun: false }, ); }); @@ -1903,7 +1942,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2010,7 +2049,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: commentsAdpt, write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2033,7 +2072,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2141,7 +2180,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: commentsAdpt, write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2172,7 +2211,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: commentsAdpt, write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2201,7 +2240,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2284,7 +2323,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: createAdpt, lists: makeListsAdapter(), @@ -2315,7 +2354,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2407,7 +2446,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: listsAdpt, @@ -2433,7 +2472,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2470,7 +2509,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), @@ -2502,7 +2541,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: createAdpt, lists: makeListsAdapter(), @@ -2527,7 +2566,7 @@ describe('createDocumentApi', () => { info: makeInfoAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: createAdpt, lists: makeListsAdapter(), @@ -2554,7 +2593,7 @@ describe('createDocumentApi', () => { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), lists: makeListsAdapter(), diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 330b184522..46f3ef7d52 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -9,6 +9,7 @@ export * from './contract/index.js'; export * from './capabilities/capabilities.js'; export * from './inline-semantics/index.js'; export type { HistoryAdapter, HistoryApi } from './history/history.js'; +export type { SelectionMutationAdapter, SelectionMutationRequest } from './selection-mutation.js'; export type { HeaderFootersAdapter, HeaderFootersApi } from './header-footers/header-footers.js'; export * from './header-footers/header-footers.types.js'; export type { ClearContentAdapter, ClearContentInput } from './clear-content/clear-content.js'; @@ -60,7 +61,6 @@ import type { DeleteInput } from './delete/delete.js'; import { executeFind, type FindAdapter } from './find/find.js'; import type { SDFindInput, SDFindResult, SDGetInput, SDNodeResult } from './types/sd-envelope.js'; import type { - FormatAdapter, FormatApi, FormatInlineAliasApi, FormatInlineAliasInput, @@ -256,6 +256,7 @@ import { executeTrackChangesDecide, } from './track-changes/track-changes.js'; import type { MutationOptions, RevisionGuardOptions, WriteAdapter } from './write/write.js'; +import type { SelectionMutationAdapter } from './selection-mutation.js'; import { executeCapabilities, type CapabilitiesAdapter, @@ -1264,7 +1265,7 @@ export { textReceiptToSDReceipt } from './receipt-bridge.js'; export { isSDAddress, isValidTarget } from './validation-primitives.js'; export type { InsertInput, InsertContentType, LegacyInsertInput } from './insert/insert.js'; export { isStructuralInsertInput } from './insert/insert.js'; -export type { ReplaceInput, LegacyReplaceInput } from './replace/replace.js'; +export type { ReplaceInput, TextReplaceInput } from './replace/replace.js'; export { isStructuralReplaceInput } from './replace/replace.js'; export { validateDocumentFragment, validateSDFragment } from './validation/fragment-validator.js'; export type { DeleteInput } from './delete/delete.js'; @@ -1544,7 +1545,7 @@ export interface DocumentApiAdapters { capabilities: CapabilitiesAdapter; comments: CommentsAdapter; write: WriteAdapter; - format: FormatAdapter; + selectionMutation: SelectionMutationAdapter; styles: StylesAdapter; trackChanges: TrackChangesAdapter; create: CreateAdapter; @@ -1604,7 +1605,7 @@ function requireAdapter(adapter: T | undefined, namespace: string): T { return adapter; } -function buildFormatInlineAliasApi(adapter: FormatAdapter): FormatInlineAliasApi { +function buildFormatInlineAliasApi(adapter: SelectionMutationAdapter): FormatInlineAliasApi { return Object.fromEntries( INLINE_PROPERTY_REGISTRY.map((entry) => { const key = entry.key as InlineRunPatchKey; @@ -1645,7 +1646,7 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return caps; }; const capabilities: CapabilitiesApi = Object.assign(capFn, { get: capFn }); - const inlineAliasApi = buildFormatInlineAliasApi(adapters.format); + const inlineAliasApi = buildFormatInlineAliasApi(adapters.selectionMutation); const api: DocumentApi = { get(input: SDGetInput): SDDocument { @@ -1699,18 +1700,18 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeInsert(adapters.write, input, options); }, replace(input: ReplaceInput, options?: MutationOptions): SDMutationReceipt { - return executeReplace(adapters.write, input, options); + return executeReplace(adapters.selectionMutation, adapters.write, input, options); }, delete(input: DeleteInput, options?: MutationOptions): TextMutationReceipt { - return executeDelete(adapters.write, input, options); + return executeDelete(adapters.selectionMutation, input, options); }, format: { ...inlineAliasApi, strikethrough(input: FormatStrikethroughInput, options?: MutationOptions): TextMutationReceipt { - return executeInlineAlias(adapters.format, 'strike', { ...input, value: true }, options); + return executeInlineAlias(adapters.selectionMutation, 'strike', { ...input, value: true }, options); }, apply(input: StyleApplyInput, options?: MutationOptions): TextMutationReceipt { - return executeStyleApply(adapters.format, input, options); + return executeStyleApply(adapters.selectionMutation, input, options); }, paragraph: { resetDirectFormatting( diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 45ca105e11..84269a8e87 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -5,7 +5,7 @@ import { buildDispatchTable } from './invoke.js'; import type { FindAdapter } from '../find/find.js'; import type { GetNodeAdapter } from '../get-node/get-node.js'; import type { WriteAdapter } from '../write/write.js'; -import type { FormatAdapter } from '../format/format.js'; +import type { SelectionMutationAdapter } from '../selection-mutation.js'; import type { StylesAdapter } from '../styles/index.js'; import type { TrackChangesAdapter } from '../track-changes/track-changes.js'; import type { CreateAdapter } from '../create/create.js'; @@ -84,16 +84,22 @@ function makeAdapters() { }, })), }; - const formatReceipt = () => ({ + const selectionMutationReceipt = () => ({ success: true as const, resolution: { - target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, - range: { from: 1, to: 3 }, + blockId: 'p1', + blockType: 'paragraph' as const, text: 'Hi', + target: { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }, + range: { start: 0, end: 2 }, }, }); - const formatAdapter: FormatAdapter = { - apply: vi.fn(formatReceipt), + const selectionMutationAdapter: SelectionMutationAdapter = { + execute: vi.fn(selectionMutationReceipt), }; const stylesAdapter: StylesAdapter = { apply: vi.fn(() => ({ @@ -237,7 +243,7 @@ function makeAdapters() { capabilities: capabilitiesAdapter, comments: commentsAdapter, write: writeAdapter, - format: formatAdapter, + selectionMutation: selectionMutationAdapter, styles: stylesAdapter, trackChanges: trackChangesAdapter, create: createAdapter, @@ -349,7 +355,11 @@ describe('invoke', () => { const { adapters } = makeAdapters(); const api = createDocumentApi(adapters); const input = { - target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, + target: { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }, inline: { bold: true }, }; const direct = api.format.apply(input); @@ -361,7 +371,11 @@ describe('invoke', () => { const { adapters } = makeAdapters(); const api = createDocumentApi(adapters); const input = { - target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, + target: { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }, value: 'Arial', }; const direct = api.format.fontFamily(input); diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index da90dd6f09..3b86259b3d 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -9,19 +9,23 @@ import { describe, expect, it, vi } from 'vitest'; import { createDocumentApi } from './index.js'; import type { DocumentApiCapabilities } from './capabilities/capabilities.js'; -import type { TextAddress } from './types/index.js'; +import type { SelectionTarget } from './types/index.js'; // --------------------------------------------------------------------------- // Shared mock-adapter factories (mirrors index.test.ts patterns) // --------------------------------------------------------------------------- -const TEXT_TARGET: TextAddress = { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }; +const SELECTION_TARGET: SelectionTarget = { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 0 }, + end: { kind: 'text', blockId: 'p1', offset: 3 }, +}; -function makeTextMutationReceipt(target = TEXT_TARGET) { +function makeTextMutationReceipt() { return { success: true as const, resolution: { - target, + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 3 } }, range: { from: 1, to: 4 }, text: 'foo', }, @@ -39,7 +43,7 @@ function makeFindAdapter() { id: 'p1', handle: { ref: 'p1', refStability: 'ephemeral' as const, targetKind: 'node' as const }, address: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }, - context: { textRanges: [TEXT_TARGET] }, + context: { textRanges: [SELECTION_TARGET] }, }, ], page: { limit: 1, offset: 0, returned: 1 }, @@ -90,9 +94,9 @@ function makeWriteAdapter() { }; } -function makeFormatAdapter() { +function makeSelectionMutationAdapter() { return { - apply: vi.fn(() => makeTextMutationReceipt()), + execute: vi.fn(() => makeTextMutationReceipt()), }; } @@ -329,7 +333,7 @@ function makeApi() { capabilities: makeCapabilitiesAdapter(), comments: makeCommentsAdapter(), write: makeWriteAdapter(), - format: makeFormatAdapter(), + selectionMutation: makeSelectionMutationAdapter(), paragraphs: makeParagraphsAdapter(), trackChanges: makeTrackChangesAdapter(), create: makeCreateAdapter(), @@ -497,7 +501,7 @@ describe('overview.mdx examples', () => { } expect(target).toBeDefined(); - expect(target?.kind).toBe('text'); + expect(target?.kind).toBe('selection'); }); }); @@ -519,7 +523,7 @@ describe('overview.mdx examples', () => { const doc = makeApi(); const caps = doc.capabilities(); - const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }; + const target = SELECTION_TARGET; if (caps.operations['format.apply'].available) { doc.format.apply({ target, inline: { bold: true } }); @@ -539,7 +543,7 @@ describe('overview.mdx examples', () => { // Mirrors the exact code block from overview.mdx § "Dry-run preview" it('insert with dryRun true', () => { const doc = makeApi(); - const target = TEXT_TARGET; + const target = { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 3 } }; const preview = doc.insert({ target, value: 'hello' }, { dryRun: true }); // preview.success tells you whether the insert would succeed @@ -591,10 +595,9 @@ describe('src/README.md workflow examples', () => { it('create comment, reply, then resolve', () => { const doc = makeApi(); - // Simulate having a find result in scope (the example assumes `result` exists) - const result = doc.find({ type: 'text', pattern: 'something' }); - const target = result.items[0]?.context?.textRanges?.[0]; - const createReceipt = doc.comments.create({ target: target!, text: 'Review this section.' }); + // Simulate having a comment target + const target = { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 3 } }; + const createReceipt = doc.comments.create({ target, text: 'Review this section.' }); // Use the comment ID from the receipt to reply const comments = doc.comments.list(); const thread = comments.items[0]; @@ -629,7 +632,7 @@ describe('src/README.md workflow examples', () => { // Mirrors the exact code block from src/README.md § "Workflow: Capabilities-Aware Branching" it('branch on per-operation capabilities', () => { const doc = makeApi(); - const target = TEXT_TARGET; + const target = SELECTION_TARGET; const caps = doc.capabilities(); if (caps.operations['format.apply'].available) { diff --git a/packages/document-api/src/receipt-bridge.ts b/packages/document-api/src/receipt-bridge.ts index 8832ae1129..fea447d2cb 100644 --- a/packages/document-api/src/receipt-bridge.ts +++ b/packages/document-api/src/receipt-bridge.ts @@ -32,18 +32,25 @@ function textAddressToSDAddress(textAddr: TextAddress): SDAddress { * - TextAddress resolution is converted to SDAddress resolution. * - Failure codes from the text pipeline are mapped through the receipt. */ +/** + * Builds the SDMutationReceipt resolution object from a TextMutationResolution. + * Carries through selectionTarget for cross-block mutations. + */ +function buildSDResolution( + resolution: import('./types/index.js').TextMutationResolution, +): SDMutationReceipt['resolution'] { + return { + ...(resolution.requestedTarget ? { requestedTarget: textAddressToSDAddress(resolution.requestedTarget) } : {}), + target: textAddressToSDAddress(resolution.target), + ...(resolution.selectionTarget ? { selectionTarget: resolution.selectionTarget } : undefined), + }; +} + export function textReceiptToSDReceipt(receipt: TextMutationReceipt): SDMutationReceipt { if (receipt.success) { return { success: true, - resolution: receipt.resolution - ? { - ...(receipt.resolution.requestedTarget - ? { requestedTarget: textAddressToSDAddress(receipt.resolution.requestedTarget) } - : {}), - target: textAddressToSDAddress(receipt.resolution.target), - } - : undefined, + resolution: receipt.resolution ? buildSDResolution(receipt.resolution) : undefined, }; } @@ -73,13 +80,6 @@ export function textReceiptToSDReceipt(receipt: TextMutationReceipt): SDMutation return { success: false, failure, - resolution: receipt.resolution - ? { - ...(receipt.resolution.requestedTarget - ? { requestedTarget: textAddressToSDAddress(receipt.resolution.requestedTarget) } - : {}), - target: textAddressToSDAddress(receipt.resolution.target), - } - : undefined, + resolution: receipt.resolution ? buildSDResolution(receipt.resolution) : undefined, }; } diff --git a/packages/document-api/src/replace/replace.ts b/packages/document-api/src/replace/replace.ts index 80e6f2f33b..f880c9b694 100644 --- a/packages/document-api/src/replace/replace.ts +++ b/packages/document-api/src/replace/replace.ts @@ -1,45 +1,62 @@ -import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; -import type { TextAddress, SDMutationReceipt } from '../types/index.js'; +/** + * Replace operation — replaces content at a contiguous document selection. + * + * Two shapes: + * - Text replacement (`text` field): routes through SelectionMutationAdapter. + * - Structural replacement (`content` field): continues through WriteAdapter.replaceStructured. + * + * Text path accepts `SelectionTarget` or `ref`. Structural path accepts + * `SDAddress`, `SelectionTarget`, or `ref`. + */ + +import type { MutationOptions } from '../types/mutation-plan.types.js'; +import type { SelectionTarget } from '../types/address.js'; +import type { SDMutationReceipt } from '../types/sd-contract.js'; import type { SDReplaceInput } from '../types/structural-input.js'; import type { SDFragment } from '../types/fragment.js'; +import type { SelectionMutationAdapter } from '../selection-mutation.js'; +import type { WriteAdapter } from '../write/write.js'; +import { normalizeMutationOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; import { isRecord, + isSDAddress, isTextAddress, - isValidTarget, assertNoUnknownFields, validateNestingPolicyValue, } from '../validation-primitives.js'; +import { isSelectionTarget } from '../validation/selection-target-validator.js'; import { validateDocumentFragment } from '../validation/fragment-validator.js'; import { textReceiptToSDReceipt } from '../receipt-bridge.js'; // --------------------------------------------------------------------------- -// Legacy string-based input shape +// Text replacement input (new shape) // --------------------------------------------------------------------------- -/** Legacy string-based input for the replace operation. */ -export interface LegacyReplaceInput { - target: TextAddress; +/** Text replacement input — uses SelectionTarget / ref. */ +export interface TextReplaceInput { + target?: SelectionTarget; + ref?: string; text: string; } // --------------------------------------------------------------------------- -// Discriminated union: legacy string shape OR structural SDFragment shape +// Discriminated union: text shape OR structural SDFragment shape // --------------------------------------------------------------------------- /** * Input payload for the `doc.replace` operation. * - * Discrimination: presence of `content` (structural) vs `text` (legacy string). + * Discrimination: presence of `content` (structural) vs `text` (text replacement). */ -export type ReplaceInput = LegacyReplaceInput | SDReplaceInput; +export type ReplaceInput = TextReplaceInput | SDReplaceInput; // --------------------------------------------------------------------------- // Allowlists // --------------------------------------------------------------------------- -const LEGACY_REPLACE_ALLOWED_KEYS = new Set(['text', 'target']); -const STRUCTURAL_REPLACE_ALLOWED_KEYS = new Set(['content', 'target', 'nestingPolicy']); +const TEXT_REPLACE_ALLOWED_KEYS = new Set(['text', 'target', 'ref']); +const STRUCTURAL_REPLACE_ALLOWED_KEYS = new Set(['content', 'target', 'ref', 'nestingPolicy']); // --------------------------------------------------------------------------- // Shape discrimination @@ -50,18 +67,47 @@ export function isStructuralReplaceInput(input: ReplaceInput): input is SDReplac return 'content' in input && input.content !== undefined; } +// --------------------------------------------------------------------------- +// Shared target validation for text path +// --------------------------------------------------------------------------- + +function validateTargetLocator(input: Record, operation: string): void { + const hasTarget = input.target !== undefined; + const hasRef = input.ref !== undefined; + + if (hasTarget && hasRef) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${operation} input must provide either "target" or "ref", not both.`, + { fields: ['target', 'ref'] }, + ); + } + + if (!hasTarget && !hasRef) { + throw new DocumentApiValidationError('INVALID_INPUT', `${operation} requires a target or ref.`, { + fields: ['target', 'ref'], + }); + } + + if (hasTarget && !isSelectionTarget(input.target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a SelectionTarget object.', { + field: 'target', + value: input.target, + }); + } + + if (hasRef && typeof input.ref !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', 'ref must be a string.', { + field: 'ref', + value: input.ref, + }); + } +} + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- -/** - * Validates ReplaceInput as either legacy or structural shape. - * - * Validation order: - * 0. Input shape guard - * 1. Union conflict detection - * 2. Shape-specific validation - */ function validateReplaceInput(input: unknown): asserts input is ReplaceInput { if (!isRecord(input)) { throw new DocumentApiValidationError('INVALID_TARGET', 'Replace input must be a non-null object.'); @@ -70,33 +116,29 @@ function validateReplaceInput(input: unknown): asserts input is ReplaceInput { const hasText = 'text' in input && input.text !== undefined; const hasContent = 'content' in input && input.content !== undefined; - // Union conflict: both discriminants present if (hasText && hasContent) { throw new DocumentApiValidationError( 'INVALID_INPUT', - 'Replace input must provide either "text" (legacy) or "content" (structural), not both.', + 'Replace input must provide either "text" or "content", not both.', { fields: ['text', 'content'] }, ); } - // Union conflict: neither discriminant present if (!hasText && !hasContent) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - 'Replace input must provide either "text" (legacy string) or "content" (SDFragment).', - { fields: ['text', 'content'] }, - ); + throw new DocumentApiValidationError('INVALID_INPUT', 'Replace input must provide either "text" or "content".', { + fields: ['text', 'content'], + }); } if (hasContent) { validateStructuralReplaceInput(input); } else { - validateLegacyReplaceInput(input); + validateTextReplaceInput(input); } } -/** Validates legacy string-based replace input. */ -function validateLegacyReplaceInput(input: Record): void { +/** Validates the text replacement path (SelectionTarget / ref + text). */ +function validateTextReplaceInput(input: Record): void { if ('nestingPolicy' in input && input.nestingPolicy !== undefined) { throw new DocumentApiValidationError( 'INVALID_INPUT', @@ -105,25 +147,13 @@ function validateLegacyReplaceInput(input: Record): void { ); } - assertNoUnknownFields(input, LEGACY_REPLACE_ALLOWED_KEYS, 'replace'); - - const { target, text } = input; - - if (target === undefined) { - throw new DocumentApiValidationError('INVALID_TARGET', 'Replace requires a target.'); - } - - if (!isTextAddress(target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { - field: 'target', - value: target, - }); - } + assertNoUnknownFields(input, TEXT_REPLACE_ALLOWED_KEYS, 'replace'); + validateTargetLocator(input, 'replace'); - if (typeof text !== 'string') { - throw new DocumentApiValidationError('INVALID_TARGET', `text must be a string, got ${typeof text}.`, { + if (typeof input.text !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `text must be a string, got ${typeof input.text}.`, { field: 'text', - value: text, + value: input.text, }); } } @@ -132,24 +162,39 @@ function validateLegacyReplaceInput(input: Record): void { function validateStructuralReplaceInput(input: Record): void { assertNoUnknownFields(input, STRUCTURAL_REPLACE_ALLOWED_KEYS, 'replace'); - const { target, content, nestingPolicy } = input; + const { target, ref: refValue, content, nestingPolicy } = input; + const hasTarget = target !== undefined; + const hasRef = refValue !== undefined; + + if (hasTarget && hasRef) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + 'Structural replace must provide either "target" or "ref", not both.', + { fields: ['target', 'ref'] }, + ); + } - if (target === undefined) { - throw new DocumentApiValidationError('INVALID_TARGET', 'Replace requires a target.'); + if (!hasTarget && !hasRef) { + throw new DocumentApiValidationError('INVALID_TARGET', 'Structural replace requires a target or ref.', { + fields: ['target', 'ref'], + }); } - // Structural path accepts both SDAddress and TextAddress - if (!isValidTarget(target)) { + if (hasTarget && !isSDAddress(target) && !isTextAddress(target) && !isSelectionTarget(target)) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'target must be a valid address (SDAddress or TextAddress).', - { - field: 'target', - value: target, - }, + 'target must be a valid address (SDAddress, TextAddress, or SelectionTarget).', + { field: 'target', value: target }, ); } + if (hasRef && typeof refValue !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', 'ref must be a string.', { + field: 'ref', + value: refValue, + }); + } + validateNestingPolicyValue(nestingPolicy); validateDocumentFragment(content as SDFragment); } @@ -159,7 +204,8 @@ function validateStructuralReplaceInput(input: Record): void { // --------------------------------------------------------------------------- export function executeReplace( - adapter: WriteAdapter, + selectionAdapter: SelectionMutationAdapter, + writeAdapter: WriteAdapter, input: ReplaceInput, options?: MutationOptions, ): SDMutationReceipt { @@ -167,10 +213,19 @@ export function executeReplace( // Structural content path — returns SDMutationReceipt directly if (isStructuralReplaceInput(input)) { - return adapter.replaceStructured(input as unknown as ReplaceInput, options); + return writeAdapter.replaceStructured(input as unknown as ReplaceInput, options); } - // Legacy string path — wrap TextMutationReceipt → SDMutationReceipt - const textReceipt = executeWrite(adapter, { kind: 'replace', target: input.target, text: input.text }, options); + // Text replacement path — route through SelectionMutationAdapter + const textInput = input as TextReplaceInput; + const textReceipt = selectionAdapter.execute( + { + kind: 'replace', + target: textInput.target, + ref: textInput.ref, + text: textInput.text, + }, + normalizeMutationOptions(options), + ); return textReceiptToSDReceipt(textReceipt); } diff --git a/packages/document-api/src/selection-mutation.ts b/packages/document-api/src/selection-mutation.ts new file mode 100644 index 0000000000..79011d7180 --- /dev/null +++ b/packages/document-api/src/selection-mutation.ts @@ -0,0 +1,53 @@ +/** + * Selection-based mutation adapter — the single execution interface for + * `delete`, `replace` (text path), and `format.apply` in the new + * SelectionTarget / ref model. + * + * This replaces the WriteAdapter for delete/replace and the FormatAdapter + * for format.apply. All three operations route through the plan engine. + */ + +import type { SelectionTarget, DeleteBehavior } from './types/address.js'; +import type { TextMutationReceipt } from './types/receipt.js'; +import type { MutationOptions } from './types/mutation-plan.types.js'; +import type { InlineRunPatch } from './format/inline-run-patch.js'; + +// --------------------------------------------------------------------------- +// Adapter request types +// --------------------------------------------------------------------------- + +export type SelectionDeleteRequest = { + kind: 'delete'; + target?: SelectionTarget; + ref?: string; + behavior: DeleteBehavior; +}; + +export type SelectionReplaceRequest = { + kind: 'replace'; + target?: SelectionTarget; + ref?: string; + text: string; +}; + +export type SelectionFormatRequest = { + kind: 'format'; + target?: SelectionTarget; + ref?: string; + inline: InlineRunPatch; +}; + +export type SelectionMutationRequest = SelectionDeleteRequest | SelectionReplaceRequest | SelectionFormatRequest; + +// --------------------------------------------------------------------------- +// Adapter interface +// --------------------------------------------------------------------------- + +/** + * Adapter that the super-editor plan engine implements for selection-based + * mutations. All three core mutation ops (delete, replace-text, format.apply) + * go through this single interface. + */ +export interface SelectionMutationAdapter { + execute(request: SelectionMutationRequest, options?: MutationOptions): TextMutationReceipt; +} diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts index 6d60c054c8..32b33179bd 100644 --- a/packages/document-api/src/types/address.ts +++ b/packages/document-api/src/types/address.ts @@ -1,3 +1,5 @@ +import type { BlockNodeType } from './base.js'; + export type Range = { /** Inclusive start offset (0-based, UTF-16 code units). */ start: number; @@ -40,6 +42,63 @@ export type TextTarget = { segments: [TextSegment, ...TextSegment[]]; }; +// --------------------------------------------------------------------------- +// Selection-based mutation targeting (v1) +// --------------------------------------------------------------------------- + +/** + * Block node types valid as `nodeEdge` selection anchors. + * + * Excludes: + * - `tableRow`, `tableCell` — row/column semantics out of scope + * - `image` — block-image deletion not yet proven end-to-end + * - `listItem` — derived from paragraph attrs, no distinct PM wrapper node + */ +export type SelectionEdgeNodeType = Exclude; + +export const SELECTION_EDGE_NODE_TYPES = [ + 'paragraph', + 'heading', + 'table', + 'tableOfContents', + 'sdt', +] as const satisfies readonly SelectionEdgeNodeType[]; + +/** Block node address valid as a `nodeEdge` selection anchor. */ +export type SelectionEdgeNodeAddress = { + kind: 'block'; + nodeType: SelectionEdgeNodeType; + nodeId: string; +}; + +/** + * A point within a document selection. + * + * - `text`: A character offset within a specific block's flattened text model. + * - `nodeEdge`: The boundary of a block-level node (before or after). + */ +export type SelectionPoint = + | { kind: 'text'; blockId: string; offset: number } + | { kind: 'nodeEdge'; node: SelectionEdgeNodeAddress; edge: 'before' | 'after' }; + +/** + * A contiguous document selection — the canonical public target for the core + * selection-mutation family (`delete`, `replace`, `format.apply`, `mutations.apply`). + * + * Other range-targeted APIs (comments, hyperlinks) continue to use `TextAddress`. + */ +export type SelectionTarget = { + kind: 'selection'; + start: SelectionPoint; + end: SelectionPoint; +}; + +/** Discriminated input for direct operations: either an explicit target or a ref string. */ +export type TargetLocator = { target: SelectionTarget; ref?: undefined } | { ref: string; target?: undefined }; + +/** Delete behavior mode. */ +export type DeleteBehavior = 'selection' | 'exact'; + export type EntityType = 'comment' | 'trackedChange'; export type CommentAddress = { diff --git a/packages/document-api/src/types/mutation-plan.types.ts b/packages/document-api/src/types/mutation-plan.types.ts index 2fa1112344..9dcdd13cd2 100644 --- a/packages/document-api/src/types/mutation-plan.types.ts +++ b/packages/document-api/src/types/mutation-plan.types.ts @@ -6,7 +6,7 @@ */ import type { NodeAddress } from './base.js'; -import type { TextAddress, TrackedChangeAddress } from './address.js'; +import type { TextAddress, TrackedChangeAddress, SelectionTarget, DeleteBehavior } from './address.js'; import type { TextSelector, NodeSelector } from './query.js'; import type { InsertStylePolicy, StylePolicy } from './style-policy.types.js'; import type { InlineRunPatch } from '../format/inline-run-patch.js'; @@ -30,7 +30,12 @@ export type RefWhere = { within?: NodeAddress; }; -export type StepWhere = SelectWhere | RefWhere; +export type TargetWhere = { + by: 'target'; + target: SelectionTarget; +}; + +export type StepWhere = SelectWhere | RefWhere | TargetWhere; export type AssertWhere = { by: 'select'; @@ -106,7 +111,10 @@ export type TextDeleteStep = { id: string; op: 'text.delete'; where: StepWhere; - args: Record; + args: { + /** Controls block-edge expansion. Defaults to `'selection'`. */ + behavior?: DeleteBehavior; + }; }; export type StyleApplyStep = { @@ -219,10 +227,18 @@ export type SpanStepResolution = { text: string; }; +/** Resolution for a selection-based target (may span multiple blocks). */ +export type SelectionStepResolution = { + selectionTarget: SelectionTarget; + range: { from: number; to: number }; + text: string; +}; + export type TextStepData = { domain: 'text'; resolutions: TextStepResolution[]; spanResolutions?: SpanStepResolution[]; + selectionResolutions?: SelectionStepResolution[]; }; export type AssertStepData = { @@ -289,6 +305,7 @@ export type StepPreview = { op: string; resolutions?: TextStepResolution[]; spanResolutions?: SpanStepResolution[]; + selectionResolutions?: SelectionStepResolution[]; style?: unknown; }; diff --git a/packages/document-api/src/types/query-match.types.ts b/packages/document-api/src/types/query-match.types.ts index 862fadaffa..ab30cdcdf9 100644 --- a/packages/document-api/src/types/query-match.types.ts +++ b/packages/document-api/src/types/query-match.types.ts @@ -9,6 +9,7 @@ */ import type { BlockNodeType, NodeAddress } from './base.js'; +import type { SelectionTarget } from './address.js'; import type { TextSelector, NodeSelector } from './query.js'; import type { DiscoveryItem, DiscoveryOutput, DiscoveryResult } from './discovery.js'; import type { InlineToggleDirective } from './style-policy.types.js'; @@ -154,6 +155,13 @@ export interface TextMatchDomain { matchKind: 'text'; /** Address of the first matched block in document order (D14). */ address: NodeAddress; + /** + * Canonical mutation-ready selection target for this text match. + * + * Can be passed directly to `doc.delete({ target })`, `doc.replace({ target, text })`, + * or `doc.format.apply({ target, inline })` for cross-block mutations. + */ + target: SelectionTarget; /** Matched text plus surrounding context (D11). */ snippet: string; /** Character offsets within `snippet` identifying the matched text (D17). */ diff --git a/packages/document-api/src/types/query.ts b/packages/document-api/src/types/query.ts index 938aae1fe4..7668ff715a 100644 --- a/packages/document-api/src/types/query.ts +++ b/packages/document-api/src/types/query.ts @@ -1,6 +1,6 @@ import type { NodeAddress, NodeKind, NodeType } from './base.js'; import type { NodeInfo } from './node.js'; -import type { Range, TextAddress } from './address.js'; +import type { Range, TextAddress, SelectionTarget } from './address.js'; import type { DiscoveryOutput } from './discovery.js'; export interface TextSelector { @@ -62,6 +62,13 @@ export interface MatchContext { address: NodeAddress; snippet: string; highlightRange: Range; + /** + * Canonical mutation-ready selection target for this text match. + * + * Built from the first and last text ranges. Can be passed directly to + * `doc.delete({ target })`, `doc.replace({ target, text })`, etc. + */ + target?: SelectionTarget; /** * Text ranges matching the query, expressed as block-relative offsets. * For cross-paragraph matches, this will include one range per block. diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index 6a9998778b..abb2fe7868 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -1,4 +1,4 @@ -import type { EntityAddress, TextAddress, TrackedChangeAddress } from './address.js'; +import type { EntityAddress, SelectionTarget, TextAddress, TrackedChangeAddress } from './address.js'; export type ReceiptInsert = TrackedChangeAddress; export type ReceiptEntity = EntityAddress; @@ -85,6 +85,8 @@ export type TextMutationResolution = { requestedTarget?: TextAddress; /** * Effective target used by the adapter after canonical resolution. + * For cross-block selections this reflects the first block only — + * use {@link selectionTarget} for the full resolved range. */ target: TextAddress; /** @@ -96,6 +98,12 @@ export type TextMutationResolution = { * Empty for collapsed insert targets. */ text: string; + /** + * Full selection target for cross-block mutations. + * Present when the resolved range spans more than one block. + * Single-block mutations omit this field. + */ + selectionTarget?: SelectionTarget; }; export type TextMutationReceipt = diff --git a/packages/document-api/src/types/sd-contract.ts b/packages/document-api/src/types/sd-contract.ts index a7ba4338fa..c4413561f4 100644 --- a/packages/document-api/src/types/sd-contract.ts +++ b/packages/document-api/src/types/sd-contract.ts @@ -2,6 +2,7 @@ * SDM/1 contract types — mutation receipts, error model, and diagnostics. */ +import type { SelectionTarget } from './address.js'; import type { SDAddress } from './sd-envelope.js'; // --------------------------------------------------------------------------- @@ -41,7 +42,12 @@ export interface SDMutationReceipt { success: boolean; failure?: SDError; evaluatedRevision?: { before: string; after: string }; - resolution?: { requestedTarget?: SDAddress; target: SDAddress }; + resolution?: { + requestedTarget?: SDAddress; + target: SDAddress; + /** Full selection target for cross-block mutations. */ + selectionTarget?: SelectionTarget; + }; } // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/types/structural-input.ts b/packages/document-api/src/types/structural-input.ts index 97ea1a942b..8fd4bdc991 100644 --- a/packages/document-api/src/types/structural-input.ts +++ b/packages/document-api/src/types/structural-input.ts @@ -8,7 +8,7 @@ */ import type { SDAddress } from './sd-envelope.js'; -import type { TextAddress } from './address.js'; +import type { SelectionTarget, TextAddress } from './address.js'; import type { SDFragment } from './fragment.js'; import type { Placement, NestingPolicy } from './placement.js'; @@ -34,8 +34,10 @@ export interface SDInsertInput { /** SDM/1 structural shape for the replace operation. */ export interface SDReplaceInput { - /** Required target range to replace. */ - target: SDAddress | TextAddress; + /** Target range to replace. Required unless `ref` is provided. */ + target?: SDAddress | TextAddress | SelectionTarget; + /** Opaque ref string (alternative to `target`). */ + ref?: string; /** Structural content to replace with. */ content: SDFragment; /** Nesting policy. Defaults to { tables: 'forbid' }. */ diff --git a/packages/document-api/src/validation/selection-target-validator.ts b/packages/document-api/src/validation/selection-target-validator.ts new file mode 100644 index 0000000000..4a74a90607 --- /dev/null +++ b/packages/document-api/src/validation/selection-target-validator.ts @@ -0,0 +1,46 @@ +/** + * Validation for SelectionTarget and its constituent types. + * + * These are shape-level guards — they check structural correctness, + * not semantic validity (e.g., whether a blockId exists in the document). + */ + +import type { SelectionTarget, SelectionPoint, SelectionEdgeNodeAddress } from '../types/address.js'; +import { SELECTION_EDGE_NODE_TYPES } from '../types/address.js'; +import { isRecord, isInteger } from '../validation-primitives.js'; + +const VALID_EDGE_VALUES: ReadonlySet = new Set(['before', 'after']); +const VALID_EDGE_NODE_TYPES: ReadonlySet = new Set(SELECTION_EDGE_NODE_TYPES); + +/** Type guard for SelectionEdgeNodeAddress. */ +export function isSelectionEdgeNodeAddress(value: unknown): value is SelectionEdgeNodeAddress { + if (!isRecord(value)) return false; + if (value.kind !== 'block') return false; + if (typeof value.nodeType !== 'string' || !VALID_EDGE_NODE_TYPES.has(value.nodeType)) return false; + if (typeof value.nodeId !== 'string' || value.nodeId === '') return false; + return true; +} + +/** Type guard for SelectionPoint. */ +export function isSelectionPoint(value: unknown): value is SelectionPoint { + if (!isRecord(value)) return false; + + if (value.kind === 'text') { + return typeof value.blockId === 'string' && value.blockId !== '' && isInteger(value.offset) && value.offset >= 0; + } + + if (value.kind === 'nodeEdge') { + return ( + isSelectionEdgeNodeAddress(value.node) && typeof value.edge === 'string' && VALID_EDGE_VALUES.has(value.edge) + ); + } + + return false; +} + +/** Type guard for SelectionTarget. */ +export function isSelectionTarget(value: unknown): value is SelectionTarget { + if (!isRecord(value)) return false; + if (value.kind !== 'selection') return false; + return isSelectionPoint(value.start) && isSelectionPoint(value.end); +} diff --git a/packages/document-api/src/write/write.ts b/packages/document-api/src/write/write.ts index 37b18c3b0c..d64d783ea3 100644 --- a/packages/document-api/src/write/write.ts +++ b/packages/document-api/src/write/write.ts @@ -1,5 +1,5 @@ import type { TextAddress, TextMutationReceipt, SDMutationReceipt } from '../types/index.js'; -import type { BlockRelativeLocator, BlockRelativeRange } from './locator.js'; +import type { BlockRelativeLocator } from './locator.js'; import type { InsertInput } from '../insert/insert.js'; import type { ReplaceInput } from '../replace/replace.js'; @@ -23,8 +23,10 @@ export interface MutationOptions extends RevisionGuardOptions { dryRun?: boolean; } -export type WriteKind = 'insert' | 'replace' | 'delete'; - +/** + * Text insertion request — the only write-kind that still routes through + * the WriteAdapter. Delete and replace now use SelectionMutationAdapter. + */ export type InsertWriteRequest = { kind: 'insert'; /** @@ -35,22 +37,16 @@ export type InsertWriteRequest = { text: string; } & Partial; -export type ReplaceWriteRequest = { - kind: 'replace'; - target?: TextAddress; - text: string; -} & Partial; - -export type DeleteWriteRequest = { - kind: 'delete'; - target?: TextAddress; - text?: ''; -} & Partial; - -export type WriteRequest = InsertWriteRequest | ReplaceWriteRequest | DeleteWriteRequest; +/** @deprecated Use `InsertWriteRequest` directly. Delete and replace now use SelectionMutationAdapter. */ +export type WriteRequest = InsertWriteRequest; +/** + * Adapter interface for write operations. After the selection-first delete + * cutover, only `insert` routes through `write()`. Delete and replace use + * `SelectionMutationAdapter` instead. + */ export interface WriteAdapter { - write(request: WriteRequest, options?: MutationOptions): TextMutationReceipt; + write(request: InsertWriteRequest, options?: MutationOptions): TextMutationReceipt; /** Structured insert for SDFragment or markdown/html content. Returns SDMutationReceipt. */ insertStructured(input: InsertInput, options?: MutationOptions): SDMutationReceipt; /** Structured replace for SDFragment content. Returns SDMutationReceipt. */ @@ -67,7 +63,7 @@ export function normalizeMutationOptions(options?: MutationOptions): MutationOpt export function executeWrite( adapter: WriteAdapter, - request: WriteRequest, + request: InsertWriteRequest, options?: MutationOptions, ): TextMutationReceipt { return adapter.write(request, normalizeMutationOptions(options)); 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 72c9087268..4ff3aeb2e3 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 @@ -23,7 +23,7 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('info.info'); expect(adapters).toHaveProperty('comments'); expect(adapters).toHaveProperty('write.write'); - expect(adapters).toHaveProperty('format.apply'); + expect(adapters).toHaveProperty('selectionMutation.execute'); expect(adapters).toHaveProperty('paragraphs.setStyle'); expect(adapters).toHaveProperty('paragraphs.clearStyle'); expect(adapters).toHaveProperty('paragraphs.resetDirectFormatting'); @@ -103,7 +103,7 @@ describe('assembleDocumentApiAdapters', () => { expect(typeof adapters.find.find).toBe('function'); expect(typeof adapters.write.write).toBe('function'); - expect(typeof adapters.format.apply).toBe('function'); + expect(typeof adapters.selectionMutation.execute).toBe('function'); expect(typeof adapters.paragraphs.setStyle).toBe('function'); expect(typeof adapters.paragraphs.setAlignment).toBe('function'); expect(typeof adapters.paragraphs.setBorder).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 e9bcd6fab8..4feb54bb1e 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -14,7 +14,7 @@ import { writeWrapper, insertStructuredWrapper, replaceStructuredWrapper, - styleApplyWrapper, + selectionMutationWrapper, } from './plan-engine/plan-wrappers.js'; import { clearContentWrapper } from './plan-engine/clear-content-wrapper.js'; import { stylesApplyAdapter } from './styles-adapter.js'; @@ -364,8 +364,8 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters insertStructured: (input, options) => insertStructuredWrapper(editor, input, options), replaceStructured: (input, options) => replaceStructuredWrapper(editor, input, options), }, - format: { - apply: (input, options) => styleApplyWrapper(editor, input, options), + selectionMutation: { + execute: (request, options) => selectionMutationWrapper(editor, request, options), }, styles: { apply: (input, options) => stylesApplyAdapter(editor, input, options), diff --git a/packages/super-editor/src/document-api-adapters/find-adapter.ts b/packages/super-editor/src/document-api-adapters/find-adapter.ts index 30e4bf6887..90697ed2a4 100644 --- a/packages/super-editor/src/document-api-adapters/find-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/find-adapter.ts @@ -23,6 +23,7 @@ import { executeDualKindSelector } from './find/dual-kind-strategy.js'; import { executeInlineSelector } from './find/inline-strategy.js'; import { executeTextSelector } from './find/text-strategy.js'; import { getRevision } from './plan-engine/revision-tracker.js'; +import { buildSelectionTargetFromTextRanges, encodeV3Ref } from './plan-engine/query-match-adapter.js'; import { projectContentNode, projectInlineNode, @@ -66,9 +67,32 @@ export function findLegacyAdapter(editor: Editor, query: Query): FindOutput { // Merge parallel arrays into per-item FindItemDomain entries. const items = result.matches.map((address, idx) => { const nodeId = 'nodeId' in address ? (address as { nodeId: string }).nodeId : undefined; - const isTextContext = result.context?.[idx]?.textRanges?.length; - const ref = nodeId ?? `find:${idx}`; - const targetKind = isTextContext ? ('text' as const) : ('node' as const); + const contextEntry = result.context?.[idx]; + const textRanges = contextEntry?.textRanges; + const isTextContext = textRanges?.length; + + // Text matches get real V3 refs so they can be chained into mutations. + // Node matches use the stable nodeId or a coarse indexed ref. + let ref: string; + let targetKind: 'text' | 'node'; + if (isTextContext && textRanges) { + const segments = textRanges.map((tr) => ({ + blockId: tr.blockId, + start: tr.range.start, + end: tr.range.end, + })); + ref = encodeV3Ref({ + v: 3, + rev: evaluatedRevision, + matchId: `f:${idx}`, + scope: 'match', + segments, + }); + targetKind = 'text'; + } else { + ref = nodeId ?? `find:${idx}`; + targetKind = 'node'; + } const handle = buildResolvedHandle(ref, 'ephemeral', targetKind); const domain: { @@ -77,7 +101,13 @@ export function findLegacyAdapter(editor: Editor, query: Query): FindOutput { context?: typeof result.context extends (infer U)[] | undefined ? U : never; } = { address }; if (includedNodes?.[idx]) domain.node = includedNodes[idx]; - if (result.context?.[idx]) domain.context = result.context[idx]; + if (contextEntry) { + // Inject mutation-ready SelectionTarget into text match contexts. + if (textRanges?.length) { + contextEntry.target = buildSelectionTargetFromTextRanges(textRanges); + } + domain.context = contextEntry; + } return buildDiscoveryItem(ref, handle, domain); }); diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.ts b/packages/super-editor/src/document-api-adapters/format-adapter.ts index e1facb1cf1..5be678fe4f 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.ts @@ -6,6 +6,7 @@ import type { FormatStrikethroughInput, MutationOptions, TextAddress, + SelectionTarget, TextMutationReceipt, } from '@superdoc/document-api'; import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; @@ -26,7 +27,15 @@ const FORMAT_OPERATION_LABEL = { type FormatOperationId = keyof typeof FORMAT_OPERATION_LABEL; type FormatMarkName = 'bold' | 'italic' | 'underline' | 'strike'; -type FormatOperationInput = { target?: TextAddress; blockId?: string; start?: number; end?: number }; +/** @deprecated Legacy format input. Use SelectionMutationAdapter for new code. */ +type FormatOperationInput = { + target?: TextAddress | SelectionTarget; + ref?: string; + blockId?: string; + start?: number; + end?: number; + value?: unknown; +}; /** * Normalize block-relative locator fields into a canonical TextAddress. @@ -34,7 +43,11 @@ type FormatOperationInput = { target?: TextAddress; blockId?: string; start?: nu * blockId + start + end → TextAddress with range { start, end }. * Returns the original input unchanged when no friendly locator is present. */ +/** @deprecated Legacy normalizer. New code uses SelectionMutationAdapter. */ function normalizeFormatLocator(input: FormatOperationInput): FormatOperationInput { + // New-style inputs: pass through when target is a SelectionTarget. + if (input.target && input.target.kind === 'selection') return input; + const hasBlockId = input.blockId !== undefined; const hasStart = input.start !== undefined; const hasEnd = input.end !== undefined; @@ -91,16 +104,17 @@ function formatMarkAdapter( ): TextMutationReceipt { checkRevision(editor, options?.expectedRevision); const normalizedInput = normalizeFormatLocator(input); - const range = resolveTextTarget(editor, normalizedInput.target!); + const textTarget = normalizedInput.target as TextAddress | undefined; + const range = resolveTextTarget(editor, textTarget!); if (!range) { throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Format target could not be resolved.', { - target: normalizedInput.target, + target: textTarget, }); } const resolution = buildTextMutationResolution({ - requestedTarget: input.target, - target: normalizedInput.target!, + requestedTarget: textTarget, + target: textTarget!, range, text: readTextAtResolvedRange(editor, range), }); diff --git a/packages/super-editor/src/document-api-adapters/helpers/expand-delete-selection.ts b/packages/super-editor/src/document-api-adapters/helpers/expand-delete-selection.ts new file mode 100644 index 0000000000..c778c1a096 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/expand-delete-selection.ts @@ -0,0 +1,142 @@ +/** + * Delete range expansion for `behavior: 'selection'`. + * + * When a boundary block is fully covered by the resolved selection, the + * selection is expanded to include the block's structural edges so the + * entire node is removed — matching the behavior of selecting text in + * the editor and pressing backspace. + * + * Expansion is evaluated per-endpoint, not all-or-nothing. An explicit + * `nodeEdge` endpoint is already at a block boundary and never expands. + * Only `text` endpoints that fully cover their boundary block expand. + */ + +import type { SelectionPoint } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { getBlockIndex } from './index-cache.js'; +import { findBlockByPos, isTextBlockCandidate, type BlockCandidate } from './node-address-resolver.js'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface ExpandedRange { + absFrom: number; + absTo: number; +} + +/** + * Optionally expands a resolved selection range to include fully-covered + * boundary blocks. + * + * @param editor - The editor instance (for document and block index access). + * @param absFrom - Resolved absolute start position. + * @param absTo - Resolved absolute end position. + * @param start - The original start selection point (used to determine expansion eligibility). + * @param end - The original end selection point (used to determine expansion eligibility). + * @returns The (possibly expanded) range. + */ +export function expandDeleteSelection( + editor: Editor, + absFrom: number, + absTo: number, + start: SelectionPoint, + end: SelectionPoint, +): ExpandedRange { + // Collapsed selections — nothing to expand. + if (absFrom === absTo) return { absFrom, absTo }; + + const index = getBlockIndex(editor); + + const expandedFrom = maybeExpandStart(index, absFrom, start); + const expandedTo = maybeExpandEnd(index, absTo, end); + + return { absFrom: expandedFrom, absTo: expandedTo }; +} + +// --------------------------------------------------------------------------- +// Per-endpoint expansion +// --------------------------------------------------------------------------- + +/** + * Expands the start endpoint to the block's structural start position + * if the boundary block is fully covered by the selection. + * + * nodeEdge endpoints are already at block boundaries — no expansion needed. + */ +function maybeExpandStart(index: ReturnType, absFrom: number, point: SelectionPoint): number { + // nodeEdge is already at a block boundary. + if (point.kind === 'nodeEdge') return absFrom; + + const block = findInnermostTextBlock(index, absFrom); + if (!block) return absFrom; + + // Only expand if the selection starts at or before the block's content start. + // Content start = block.pos + 1 (skip the opening token of the node). + const contentStart = block.pos + 1; + if (absFrom <= contentStart) { + return block.pos; + } + + return absFrom; +} + +/** + * Expands the end endpoint to the block's structural end position + * if the boundary block is fully covered by the selection. + * + * nodeEdge endpoints are already at block boundaries — no expansion needed. + */ +function maybeExpandEnd(index: ReturnType, absTo: number, point: SelectionPoint): number { + // nodeEdge is already at a block boundary. + if (point.kind === 'nodeEdge') return absTo; + + const block = findInnermostTextBlock(index, absTo); + if (!block) return absTo; + + // Content end = block.end - 1 (skip the closing token of the node). + const contentEnd = block.end - 1; + if (absTo >= contentEnd) { + return block.end; + } + + return absTo; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Finds the innermost text block containing the given position. + * + * The block index may contain nested blocks (e.g. table > row > cell > paragraph). + * We want the innermost text block because expansion should be bounded by + * the direct container of the text content. + */ +function findInnermostTextBlock(index: ReturnType, pos: number): BlockCandidate | undefined { + // Walk candidates to find the innermost text block containing pos. + // Candidates are ordered by document position. We find all containing + // candidates and pick the smallest (most nested) text block. + let best: BlockCandidate | undefined; + + // Start with a quick binary search hit. + const initial = findBlockByPos(index, pos); + if (!initial) return undefined; + + // Check all candidates that contain this position, looking for + // the innermost text block. + for (const candidate of index.candidates) { + if (candidate.pos > pos) break; // Past the position — stop scanning. + if (candidate.end < pos) continue; // Doesn't contain the position. + + if (!isTextBlockCandidate(candidate)) continue; + + // Prefer the candidate with the smallest range (most nested). + if (!best || candidate.end - candidate.pos < best.end - best.pos) { + best = candidate; + } + } + + return best; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/selection-target-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/selection-target-resolver.ts new file mode 100644 index 0000000000..95c222abbf --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/selection-target-resolver.ts @@ -0,0 +1,183 @@ +/** + * Selection target resolver — resolves a SelectionTarget to absolute + * ProseMirror positions against the current document state. + * + * Handles both text-point and nodeEdge-point resolution. This is the + * single source of truth for SelectionTarget → absolute position mapping. + */ + +import type { SelectionTarget, SelectionPoint, SelectionEdgeNodeAddress } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { getBlockIndex } from './index-cache.js'; +import { isTextBlockCandidate, type BlockCandidate, type BlockIndex } from './node-address-resolver.js'; +import { resolveTextRangeInBlock } from './text-offset-resolver.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +// --------------------------------------------------------------------------- +// Resolution result +// --------------------------------------------------------------------------- + +export interface ResolvedSelectionTarget { + /** Absolute PM position of the selection start. */ + absFrom: number; + /** Absolute PM position of the selection end. */ + absTo: number; + /** The canonical text snapshot across the resolved range. */ + text: string; +} + +// --------------------------------------------------------------------------- +// Text-point resolution +// --------------------------------------------------------------------------- + +/** + * Resolves a `kind: 'text'` selection point to an absolute PM position. + * + * Looks up the block by `blockId`, validates it is a text block, and + * maps the character offset to an absolute document position. + */ +function resolveTextPoint( + editor: Editor, + index: BlockIndex, + point: { kind: 'text'; blockId: string; offset: number }, +): number { + const candidate = findTextBlockByNodeId(index, point.blockId); + if (!candidate) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Block "${point.blockId}" not found.`, { + field: 'blockId', + value: point.blockId, + }); + } + + const resolved = resolveTextRangeInBlock(candidate.node, candidate.pos, { + start: point.offset, + end: point.offset, + }); + if (!resolved) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Offset ${point.offset} is out of range in block "${point.blockId}".`, + { field: 'offset', value: point.offset, blockId: point.blockId }, + ); + } + + return resolved.from; +} + +// --------------------------------------------------------------------------- +// Node-edge resolution +// --------------------------------------------------------------------------- + +/** + * Resolves a `kind: 'nodeEdge'` selection point to an absolute PM position. + * + * Finds the block node by `nodeId` and `nodeType`, then returns the + * position immediately before (node start) or after (node end) the node. + */ +function resolveNodeEdgePoint( + index: BlockIndex, + point: { kind: 'nodeEdge'; node: SelectionEdgeNodeAddress; edge: 'before' | 'after' }, +): number { + const { node, edge } = point; + const candidate = findBlockByTypeAndId(index, node.nodeType, node.nodeId); + if (!candidate) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Node "${node.nodeType}" with id "${node.nodeId}" not found.`, + { field: 'nodeId', value: node.nodeId, nodeType: node.nodeType }, + ); + } + + // Validate the resolved node type matches what the caller specified. + if (candidate.nodeType !== node.nodeType) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Node "${node.nodeId}" has type "${candidate.nodeType}", expected "${node.nodeType}".`, + { field: 'nodeType', expected: node.nodeType, actual: candidate.nodeType }, + ); + } + + return edge === 'before' ? candidate.pos : candidate.end; +} + +// --------------------------------------------------------------------------- +// Point dispatch +// --------------------------------------------------------------------------- + +function resolvePoint(editor: Editor, index: BlockIndex, point: SelectionPoint): number { + if (point.kind === 'text') { + return resolveTextPoint(editor, index, point); + } + return resolveNodeEdgePoint(index, point); +} + +/** + * Resolves a single SelectionPoint to an absolute PM position. + * + * This is a convenience wrapper for callers that need to resolve an + * individual point without building a full ResolvedSelectionTarget. + */ +export function resolveSelectionPointPosition(editor: Editor, point: SelectionPoint): number { + const index = getBlockIndex(editor); + return resolvePoint(editor, index, point); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Resolves a SelectionTarget to absolute PM positions. + * + * Validates that the resolved `absFrom <= absTo` (normalizing the selection + * direction). Returns the resolved positions and a text snapshot. + */ +export function resolveSelectionTarget(editor: Editor, target: SelectionTarget): ResolvedSelectionTarget { + const index = getBlockIndex(editor); + + const rawFrom = resolvePoint(editor, index, target.start); + const rawTo = resolvePoint(editor, index, target.end); + + // Normalize direction: absFrom must be <= absTo. + const absFrom = Math.min(rawFrom, rawTo); + const absTo = Math.max(rawFrom, rawTo); + + const text = editor.state.doc.textBetween(absFrom, absTo, '\n', '\ufffc'); + + return { absFrom, absTo, text }; +} + +// --------------------------------------------------------------------------- +// Block lookup helpers +// --------------------------------------------------------------------------- + +/** + * Finds a text-block candidate by its nodeId. Rejects ambiguous matches. + */ +function findTextBlockByNodeId(index: BlockIndex, nodeId: string): BlockCandidate | undefined { + const matches = index.candidates.filter((c) => c.nodeId === nodeId && isTextBlockCandidate(c)); + + if (matches.length > 1) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Block ID "${nodeId}" is ambiguous: matched ${matches.length} text blocks.`, + { blockId: nodeId, matchCount: matches.length }, + ); + } + + return matches[0]; +} + +/** + * Finds a block candidate by nodeType and nodeId. + * Uses the block index's byId map for O(1) lookup. + */ +function findBlockByTypeAndId(index: BlockIndex, nodeType: string, nodeId: string): BlockCandidate | undefined { + const key = `${nodeType}:${nodeId}`; + + if (index.ambiguous.has(key)) { + throw new DocumentApiAdapterError('AMBIGUOUS_TARGET', `Multiple blocks share key "${key}".`, { nodeType, nodeId }); + } + + return index.byId.get(key); +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts index c0b2fee325..880fff4caf 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts @@ -13,6 +13,7 @@ import type { NodeSelector, SelectWhere, RefWhere, + TargetWhere, TextAddress, } from '@superdoc/document-api'; import { MAX_PLAN_STEPS, MAX_PLAN_RESOLVED_TARGETS, isPublicMutationStepOp } from '@superdoc/document-api'; @@ -21,6 +22,7 @@ import type { CompiledTarget, CompiledRangeTarget, CompiledSpanTarget, + CompiledSelectionTarget, CompiledSegment, } from './executor-registry.types.js'; import { planError } from './errors.js'; @@ -32,6 +34,8 @@ import { executeTextSelector } from '../find/text-strategy.js'; import { executeBlockSelector } from '../find/block-strategy.js'; import { isTextBlockCandidate, type BlockCandidate, type BlockIndex } from '../helpers/node-address-resolver.js'; import { resolveTextRangeInBlock } from '../helpers/text-offset-resolver.js'; +import { resolveSelectionTarget, resolveSelectionPointPosition } from '../helpers/selection-target-resolver.js'; +import { expandDeleteSelection } from '../helpers/expand-delete-selection.js'; export interface CompiledStep { step: MutationStep; @@ -64,6 +68,10 @@ function isRefWhere(where: MutationStep['where']): where is RefWhere { return where.by === 'ref'; } +function isTargetWhere(where: MutationStep['where']): where is TargetWhere { + return where.by === 'target'; +} + // --------------------------------------------------------------------------- // Create-step position validation // --------------------------------------------------------------------------- @@ -106,10 +114,18 @@ function resolveCreateAnchorFromTargets( if (target.kind === 'range') return target.blockId; - const segments = target.segments; - if (!segments.length) throw planError('INVALID_INPUT', 'span target has no segments', stepId); + if (target.kind === 'span') { + const segments = target.segments; + if (!segments.length) throw planError('INVALID_INPUT', 'span target has no segments', stepId); + return position === 'before' ? segments[0].blockId : segments[segments.length - 1].blockId; + } - return position === 'before' ? segments[0].blockId : segments[segments.length - 1].blockId; + // CompiledSelectionTarget — create ops should not typically receive these, + // but handle gracefully. Use segments if available. + if (target.segments && target.segments.length > 0) { + return position === 'before' ? target.segments[0].blockId : target.segments[target.segments.length - 1].blockId; + } + throw planError('INVALID_INPUT', 'selection target has no block identity for create anchor', stepId); } /** @@ -726,6 +742,53 @@ function resolveRefTargets(editor: Editor, index: BlockIndex, step: MutationStep return dispatchRefHandler(editor, index, step, where.ref); } +// --------------------------------------------------------------------------- +// Target-where resolution (where.by: 'target') +// --------------------------------------------------------------------------- + +/** + * Resolves a `where.by: 'target'` clause into a single CompiledSelectionTarget. + * + * Uses the selection-target-resolver to map the SelectionTarget to absolute + * PM positions. For `text.delete` with `behavior: 'selection'`, applies + * block-edge expansion so fully-covered boundary blocks are removed. + */ +function resolveTargetWhereClause(editor: Editor, step: MutationStep, where: TargetWhere): CompiledSelectionTarget { + const resolved = resolveSelectionTarget(editor, where.target); + + let { absFrom, absTo } = resolved; + + // Apply delete expansion for `behavior: 'selection'`. + // After position normalization, absFrom may correspond to target.end if + // the caller passed a reversed selection. Determine which original point + // maps to each physical boundary for correct per-endpoint expansion. + const args = step.args as Record | undefined; + if (step.op === 'text.delete' && args?.behavior !== 'exact') { + const rawStartPos = resolveSelectionPointPosition(editor, where.target.start); + const fromPoint = rawStartPos === absFrom ? where.target.start : where.target.end; + const toPoint = rawStartPos === absFrom ? where.target.end : where.target.start; + const expanded = expandDeleteSelection(editor, absFrom, absTo, fromPoint, toPoint); + absFrom = expanded.absFrom; + absTo = expanded.absTo; + } + + // Re-snapshot text after potential expansion. + const text = + absFrom !== resolved.absFrom || absTo !== resolved.absTo + ? editor.state.doc.textBetween(absFrom, absTo, '\n', '\ufffc') + : resolved.text; + + return { + kind: 'selection', + stepId: step.id, + op: step.op, + absFrom, + absTo, + normalizedTarget: where.target, + text, + }; +} + // --------------------------------------------------------------------------- // Step target resolution // --------------------------------------------------------------------------- @@ -734,10 +797,14 @@ function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationSte const where = step.where; const refWhere = isRefWhere(where) ? where : undefined; const selectWhere = isSelectWhere(where) ? where : undefined; + const targetWhere = isTargetWhere(where) ? where : undefined; let targets: CompiledTarget[]; - if (refWhere) { + if (targetWhere) { + const selectionTarget = resolveTargetWhereClause(editor, step, targetWhere); + targets = [selectionTarget]; + } else if (refWhere) { targets = resolveRefTargets(editor, index, step, refWhere); } else if (selectWhere) { const isStructuralOp = step.op === 'structural.insert' || step.op === 'structural.replace'; @@ -794,6 +861,11 @@ function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationSte return t.blockId !== prev.blockId || t.from !== prev.from || t.to !== prev.to; }); + // Target-where always produces exactly one target — return immediately. + if (targetWhere) { + return targets; + } + if (refWhere) { if (targets.length === 0) { throw planError('MATCH_NOT_FOUND', `ref "${refWhere.ref}" did not resolve to any targets`, step.id, { @@ -941,9 +1013,13 @@ function normalizeOpForMatrix(op: string): string { * Classify the overlap relationship between two steps' target ranges. * Returns undefined if the ranges are disjoint (different blocks, no overlap). */ -function classifyOverlap(stepA: CompiledStep, stepB: CompiledStep): OverlapClassification | undefined { - const rangesA = extractBlockRanges(stepA); - const rangesB = extractBlockRanges(stepB); +function classifyOverlap( + stepA: CompiledStep, + stepB: CompiledStep, + index: BlockIndex, +): OverlapClassification | undefined { + const rangesA = extractBlockRanges(stepA, index); + const rangesB = extractBlockRanges(stepB, index); const opA = normalizeOpForMatrix(stepA.step.op); const opB = normalizeOpForMatrix(stepB.step.op); @@ -979,20 +1055,71 @@ function classifyOverlap(stepA: CompiledStep, stepB: CompiledStep): OverlapClass return undefined; } -function extractBlockRanges(compiled: CompiledStep): Map> { +/** + * Extracts per-block absolute ranges for overlap detection. + * + * All target kinds are normalized to absolute positions keyed by blockId. + * CompiledSelectionTargets scan the block index to enumerate every block + * whose PM range intersects `[absFrom, absTo)`, ensuring middle blocks in + * cross-block selections are correctly registered for overlap checks. + */ +function extractBlockRanges( + compiled: CompiledStep, + index: BlockIndex, +): Map> { const result = new Map>(); for (const target of compiled.targets) { if (target.kind === 'range') { - pushBlockRange(result, target.blockId, target.from, target.to); - } else { + // Use absolute positions for consistent comparison across target kinds. + pushBlockRange(result, target.blockId, target.absFrom, target.absTo); + } else if (target.kind === 'span') { for (const seg of target.segments) { - pushBlockRange(result, seg.blockId, seg.from, seg.to); + pushBlockRange(result, seg.blockId, seg.absFrom, seg.absTo); + } + } else { + // CompiledSelectionTarget — scan the block index for every block + // whose range overlaps [absFrom, absTo). This correctly captures + // start, middle, and end blocks for cross-block selections, as well + // as nodeEdge-only selections. + const sel = target; + const coveredBlocks = findCoveredBlocks(index, sel.absFrom, sel.absTo); + + if (coveredBlocks.length > 0) { + for (const blockId of coveredBlocks) { + pushBlockRange(result, blockId, sel.absFrom, sel.absTo); + } + } else { + // Degenerate case: no blocks found in range (shouldn't happen in + // practice). Register the full absolute range under a synthetic key + // so overlap detection still catches collisions. + pushBlockRange(result, `__unresolved_${sel.absFrom}_${sel.absTo}__`, sel.absFrom, sel.absTo); } } } return result; } +/** + * Finds all block candidate nodeIds whose PM range intersects `[absFrom, absTo)`. + * Returns a deduplicated list of nodeIds in document order. + */ +function findCoveredBlocks(index: BlockIndex, absFrom: number, absTo: number): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const candidate of index.candidates) { + // candidate.pos = start of the node, candidate.end = end of the node + // A block is covered if its range overlaps with [absFrom, absTo). + if (candidate.end <= absFrom || candidate.pos >= absTo) continue; + if (!seen.has(candidate.nodeId)) { + seen.add(candidate.nodeId); + result.push(candidate.nodeId); + } + } + + return result; +} + function pushBlockRange( map: Map>, blockId: string, @@ -1011,7 +1138,7 @@ function pushBlockRange( * Validates step interactions for all compiled step pairs using the interaction matrix. * Disjoint pairs are always allowed without consulting the matrix. */ -function validateStepInteractions(steps: CompiledStep[]): void { +function validateStepInteractions(steps: CompiledStep[], index: BlockIndex): void { for (let i = 0; i < steps.length; i++) { for (let j = i + 1; j < steps.length; j++) { const stepA = steps[i]; @@ -1020,7 +1147,7 @@ function validateStepInteractions(steps: CompiledStep[]): void { // Exempt non-mutating ops if (MATRIX_EXEMPT_OPS.has(stepA.step.op) || MATRIX_EXEMPT_OPS.has(stepB.step.op)) continue; - const overlap = classifyOverlap(stepA, stepB); + const overlap = classifyOverlap(stepA, stepB, index); if (!overlap) continue; // Disjoint — always allowed const opA = normalizeOpForMatrix(stepA.step.op); @@ -1162,7 +1289,7 @@ export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan ); } - validateStepInteractions(mutationSteps); + validateStepInteractions(mutationSteps, index); return { mutationSteps, assertSteps, compiledRevision }; } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.types.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.types.ts index 196adaaaca..bf4df14f1d 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.types.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.types.ts @@ -7,7 +7,7 @@ import type { Transaction } from 'prosemirror-state'; import type { Mapping } from 'prosemirror-transform'; -import type { StepOutcome, StepOutcomeData, MutationStep } from '@superdoc/document-api'; +import type { StepOutcome, StepOutcomeData, MutationStep, SelectionTarget } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import type { CapturedStyle } from './style-resolver.js'; @@ -56,7 +56,30 @@ export interface CompiledSpanTarget { capturedStyleBySegment?: CapturedStyle[]; } -export type CompiledTarget = CompiledRangeTarget | CompiledSpanTarget; +/** + * Selection-based compiled target — produced by `where.by: 'target'`. + * + * Uses absolute PM positions directly, without block-relative text offsets. + * This is the canonical internal shape for explicit SelectionTarget inputs, + * including nodeEdge boundaries that have no block-relative representation. + */ +export interface CompiledSelectionTarget { + kind: 'selection'; + stepId: string; + op: string; + absFrom: number; + absTo: number; + /** The normalized SelectionTarget (direction-corrected). */ + normalizedTarget: SelectionTarget; + /** Canonical text snapshot using doc.textBetween projection. */ + text: string; + /** Optional per-segment detail when the selection spans multiple blocks. */ + segments?: CompiledSegment[]; + /** Captured inline style data for style-preserving operations. */ + capturedStyle?: CapturedStyle; +} + +export type CompiledTarget = CompiledRangeTarget | CompiledSpanTarget | CompiledSelectionTarget; // --------------------------------------------------------------------------- // Executor context and interface diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/index.ts b/packages/super-editor/src/document-api-adapters/plan-engine/index.ts index 9f543b3182..27bba0f643 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/index.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/index.ts @@ -11,5 +11,17 @@ export { registerStepExecutor, getStepExecutor, hasStepExecutor, clearExecutorRe export { planError, PlanError } from './errors.js'; export { captureRunsInRange, resolveInlineStyle } from './style-resolver.js'; export type { CapturedRun, CapturedStyle } from './style-resolver.js'; -export type { CompiledTarget, StepExecutor, CompileContext, ExecuteContext } from './executor-registry.types.js'; -export { writeWrapper, insertStructuredWrapper, replaceStructuredWrapper, styleApplyWrapper } from './plan-wrappers.js'; +export type { + CompiledTarget, + CompiledSelectionTarget, + StepExecutor, + CompileContext, + ExecuteContext, +} from './executor-registry.types.js'; +export { + writeWrapper, + insertStructuredWrapper, + replaceStructuredWrapper, + styleApplyWrapper, + selectionMutationWrapper, +} from './plan-wrappers.js'; diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 70964b62eb..06a8afccbb 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -24,6 +24,13 @@ import type { SDReplaceInput, ReplaceInput, SDAddress, + StepWhere, + SelectionMutationRequest, + SelectionTarget, + SelectionPoint, + SelectionEdgeNodeType, + StepOutcome, + SelectionStepResolution, } from '@superdoc/document-api'; import { isStructuralInsertInput, @@ -33,9 +40,10 @@ import { } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import type { CompiledPlan } from './compiler.js'; -import type { CompiledTarget } from './executor-registry.types.js'; +import { compilePlan } from './compiler.js'; +import type { CompiledTarget, CompiledSelectionTarget } from './executor-registry.types.js'; import { executeCompiledPlan } from './executor.js'; -import { getRevision } from './revision-tracker.js'; +import { checkRevision, getRevision } from './revision-tracker.js'; import { compoundMutation } from '../../core/parts/mutation/compound-mutation.js'; import { DocumentApiAdapterError } from '../errors.js'; import { @@ -63,6 +71,14 @@ import { resolveInsertTarget as resolveStructuralInsertTarget, resolvePlacement, } from '../structural-write-engine/index.js'; +import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js'; +import { getBlockIndex } from '../helpers/index-cache.js'; +import { + findBlockByNodeIdOnly, + isTextBlockCandidate, + type BlockCandidate, + type BlockIndex, +} from '../helpers/node-address-resolver.js'; // --------------------------------------------------------------------------- // Helpers @@ -166,75 +182,42 @@ function narrowToTextAddress(target: SDAddress | TextAddress): TextAddress { // --------------------------------------------------------------------------- function normalizeWriteLocator(request: WriteRequest): WriteRequest { - if (request.kind === 'insert') { - const hasBlockId = request.blockId !== undefined; - const hasOffset = request.offset !== undefined; - - if (hasOffset && request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { - fields: ['target', 'offset'], - }); - } - if (hasOffset && !hasBlockId) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { - fields: ['offset', 'blockId'], - }); - } - if (!hasBlockId) return request; - if (request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { - fields: ['target', 'blockId'], - }); - } + const hasBlockId = request.blockId !== undefined; + const hasOffset = request.offset !== undefined; - const effectiveOffset = request.offset ?? 0; - const target: TextAddress = { - kind: 'text', - blockId: request.blockId!, - range: { start: effectiveOffset, end: effectiveOffset }, - }; - return { kind: 'insert', target, text: request.text }; + if (hasOffset && request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { + fields: ['target', 'offset'], + }); } - - if (request.kind === 'replace' || request.kind === 'delete') { - const hasBlockId = request.blockId !== undefined; - const hasStart = request.start !== undefined; - const hasEnd = request.end !== undefined; - - if (request.target && (hasBlockId || hasStart || hasEnd)) { - throw new DocumentApiAdapterError( - 'INVALID_TARGET', - `Cannot combine target with blockId/start/end on ${request.kind} request.`, - { fields: ['target', 'blockId', 'start', 'end'] }, - ); - } - if (!hasBlockId && (hasStart || hasEnd)) { - throw new DocumentApiAdapterError('INVALID_TARGET', `start/end require blockId on ${request.kind} request.`, { - fields: ['blockId', 'start', 'end'], - }); - } - if (!hasBlockId) return request; - if (!hasStart || !hasEnd) { - throw new DocumentApiAdapterError( - 'INVALID_TARGET', - `blockId requires both start and end on ${request.kind} request.`, - { fields: ['blockId', 'start', 'end'] }, - ); - } - - const target: TextAddress = { - kind: 'text', - blockId: request.blockId!, - range: { start: request.start!, end: request.end! }, - }; - if (request.kind === 'replace') return { kind: 'replace', target, text: request.text }; - return { kind: 'delete', target, text: '' }; + if (hasOffset && !hasBlockId) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { + fields: ['offset', 'blockId'], + }); + } + if (!hasBlockId) return request; + if (request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { + fields: ['target', 'blockId'], + }); } - return request; + const effectiveOffset = request.offset ?? 0; + const target: TextAddress = { + kind: 'text', + blockId: request.blockId!, + range: { start: effectiveOffset, end: effectiveOffset }, + }; + return { kind: 'insert', target, text: request.text }; } -type FormatOperationInput = { target?: TextAddress; blockId?: string; start?: number; end?: number }; +type FormatOperationInput = { + target?: TextAddress | SelectionTarget; + ref?: string; + blockId?: string; + start?: number; + end?: number; +}; function normalizeFormatLocator(input: FormatOperationInput): FormatOperationInput { const hasBlockId = input.blockId !== undefined; @@ -348,29 +331,20 @@ export function executeDomainCommand( // --------------------------------------------------------------------------- function validateWriteRequest(request: WriteRequest, resolved: ResolvedWrite): ReceiptFailure | null { - if (request.kind === 'insert') { - if (!request.text) return { code: 'INVALID_TARGET', message: 'Insert operations require non-empty text.' }; - if (resolved.range.from !== resolved.range.to) { - return { code: 'INVALID_TARGET', message: 'Insert operations require a collapsed target range.' }; - } - return null; - } - if (request.kind === 'replace') { - if (request.text == null || request.text.length === 0) { - return { code: 'INVALID_TARGET', message: 'Replace operations require non-empty text. Use delete for removals.' }; - } - if (resolved.resolution.text === request.text) { - return { code: 'NO_OP', message: 'Replace operation produced no change.' }; - } - return null; - } - // delete - if (resolved.range.from === resolved.range.to) { - return { code: 'NO_OP', message: 'Delete operation produced no change for a collapsed range.' }; + if (!request.text) return { code: 'INVALID_TARGET', message: 'Insert operations require non-empty text.' }; + if (resolved.range.from !== resolved.range.to) { + return { code: 'INVALID_TARGET', message: 'Insert operations require a collapsed target range.' }; } return null; } +/** + * Write wrapper for insert operations only. + * + * Delete and replace now route through `selectionMutationWrapper` via + * `SelectionMutationAdapter`. This wrapper handles the legacy insert path + * that still uses `TextAddress`-based `InsertWriteRequest`. + */ export function writeWrapper(editor: Editor, request: WriteRequest, options?: MutationOptions): TextMutationReceipt { const normalizedRequest = normalizeWriteLocator(request); @@ -396,7 +370,7 @@ export function writeWrapper(editor: Editor, request: WriteRequest, options?: Mu // Structural-end: the doc ends with non-text blocks. Create a paragraph // containing the text at the structural document end via a domain command, // since raw `tr.insert(pos, textNode)` cannot place text between blocks. - if (resolved.structuralEnd && normalizedRequest.kind === 'insert') { + if (resolved.structuralEnd) { const insertPos = resolved.range.from; const text = normalizedRequest.text ?? ''; const receipt = executeDomainCommand( @@ -412,40 +386,15 @@ export function writeWrapper(editor: Editor, request: WriteRequest, options?: Mu } // Build single-step compiled plan with pre-resolved target. - // The step's `where` clause is a structural stub — it is never evaluated - // because targets are already resolved. const stepId = uuidv4(); - let op: string; - let stepDef: { id: string; op: string; where: typeof STUB_WHERE; args: unknown }; - - if (normalizedRequest.kind === 'insert') { - op = 'text.insert'; - stepDef = { - id: stepId, - op, - where: STUB_WHERE, - args: { position: 'before', content: { text: normalizedRequest.text ?? '' } }, - }; - } else if (normalizedRequest.kind === 'replace') { - op = 'text.rewrite'; - stepDef = { - id: stepId, - op, - where: STUB_WHERE, - args: { replacement: { text: normalizedRequest.text ?? '' }, style: { inline: { mode: 'preserve' } } }, - }; - } else { - op = 'text.delete'; - stepDef = { - id: stepId, - op, - where: STUB_WHERE, - args: {}, - }; - } + const step = { + id: stepId, + op: 'text.insert', + where: STUB_WHERE, + args: { position: 'before', content: { text: normalizedRequest.text ?? '' } }, + } as unknown as MutationStep; - const step = stepDef as unknown as MutationStep; - const target = toCompiledTarget(stepId, op, resolved); + const target = toCompiledTarget(stepId, 'text.insert', resolved); const compiled: CompiledPlan = { mutationSteps: [{ step, targets: [target] }], assertSteps: [], @@ -534,13 +483,15 @@ function ensureTrackedInlinePropertySupport(keys: readonly InlineRunPatchKey[]): ); } +/** @deprecated Legacy wrapper. New code routes through selectionMutationWrapper. */ export function styleApplyWrapper( editor: Editor, input: StyleApplyInput, options?: MutationOptions, ): TextMutationReceipt { - const normalizedInput = normalizeFormatLocator(input); - const resolved = resolveFormatTarget(editor, normalizedInput.target!, 'format.apply'); + const normalizedInput = normalizeFormatLocator(input as unknown as FormatOperationInput); + const textTarget = normalizedInput.target as TextAddress | undefined; + const resolved = resolveFormatTarget(editor, textTarget!, 'format.apply'); if (resolved.range.from === resolved.range.to) { return { @@ -576,9 +527,9 @@ export function styleApplyWrapper( kind: 'range', stepId, op: 'format.apply', - blockId: normalizedInput.target!.blockId, - from: normalizedInput.target!.range.start, - to: normalizedInput.target!.range.end, + blockId: textTarget!.blockId, + from: textTarget!.range.start, + to: textTarget!.range.end, absFrom: resolved.range.from, absTo: resolved.range.to, text: resolved.resolution.text, @@ -599,6 +550,233 @@ export function styleApplyWrapper( return mapPlanReceiptToTextReceipt(receipt, resolved.resolution); } +// --------------------------------------------------------------------------- +// Selection mutation wrapper — routes delete/replace/format through the +// compiler's where.by: 'target' or where.by: 'ref' path. +// --------------------------------------------------------------------------- + +/** + * Builds the `where` clause for a selection mutation request. + * Returns either `{ by: 'target', target }` or `{ by: 'ref', ref }`. + */ +function buildSelectionWhere(request: SelectionMutationRequest): StepWhere { + if (request.target) { + return { by: 'target', target: request.target }; + } + if (request.ref) { + return { by: 'ref', ref: request.ref }; + } + throw new DocumentApiAdapterError('INVALID_TARGET', 'Selection mutation requires either target or ref.'); +} + +/** + * Maps a SelectionMutationRequest to a plan step op and args. + */ +function buildSelectionStepDef(stepId: string, request: SelectionMutationRequest, where: StepWhere): MutationStep { + switch (request.kind) { + case 'delete': + return { + id: stepId, + op: 'text.delete', + where, + args: { behavior: request.behavior }, + } as unknown as MutationStep; + + case 'replace': + return { + id: stepId, + op: 'text.rewrite', + where, + args: { + replacement: { text: request.text }, + style: { inline: { mode: 'preserve' } }, + }, + } as unknown as MutationStep; + + case 'format': + return { + id: stepId, + op: 'format.apply', + where, + args: { inline: request.inline }, + } as unknown as MutationStep; + } +} + +/** + * Bridge between SelectionMutationAdapter.execute() and the plan engine. + * + * Builds a one-step MutationPlan with a proper where clause and routes + * it through compile → validate → execute. This is the single execution + * path for all selection-based mutations (delete, replace-text, format.apply). + */ +export function selectionMutationWrapper( + editor: Editor, + request: SelectionMutationRequest, + options?: MutationOptions, +): TextMutationReceipt { + const mode = options?.changeMode ?? 'direct'; + if (mode === 'tracked') ensureTrackedCapability(editor, { operation: request.kind }); + + // Capability checks for format operations. + if (request.kind === 'format') { + const inlineKeys = Object.keys(request.inline) as InlineRunPatchKey[]; + ensureInlinePropertyCapabilities(editor, inlineKeys); + if (mode === 'tracked') ensureTrackedInlinePropertySupport(inlineKeys); + } + + const stepId = uuidv4(); + const where = buildSelectionWhere(request); + const step = buildSelectionStepDef(stepId, request, where); + + // Compile the one-step plan through the real compiler. + // Compilation is side-effect-free — it resolves targets against the current + // document state without mutating anything. + const compiled = compilePlan(editor, [step]); + + // Enforce expectedRevision even on dry-run — callers need to know if the + // document has drifted since their last query, regardless of execution. + checkRevision(editor, options?.expectedRevision); + + // Dry-run: compile and resolve, but do NOT execute. + if (options?.dryRun) { + const resolution = buildSelectionResolutionFromCompiled(compiled, stepId); + return { success: true, resolution }; + } + + // Execute through the shared execution engine. + const receipt = executeCompiledPlan(editor, compiled, { + changeMode: mode, + expectedRevision: options?.expectedRevision, + }); + + // Map PlanReceipt → TextMutationReceipt. + const stepOutcome = receipt.steps.find((s) => s.stepId === stepId); + const resolution = buildSelectionResolutionFromOutcome(stepOutcome, compiled, stepId); + + const success = stepOutcome?.effect === 'changed'; + if (!success) { + return { + success: false, + resolution, + failure: { code: 'NO_OP', message: `${request.kind} produced no change.` }, + }; + } + + return { success: true, resolution }; +} + +/** + * Extracts a backward-compatible blockId from a SelectionPoint. + * + * For `text` points the blockId is the point's own blockId. + * For `nodeEdge` points we use the addressed node's nodeId — this is the + * block-level node that the edge refers to, which is the closest valid + * block identifier we can provide for the legacy TextAddress shape. + */ +function blockIdFromPoint(point: SelectionPoint): string { + return point.kind === 'text' ? point.blockId : point.node.nodeId; +} + +/** + * Converts a SelectionTarget and its absolute range into a TextMutationResolution. + * + * The backward-compatible `target` (TextAddress) is derived from the start + * point — nodeEdge points use the node's nodeId so callers always get a + * meaningful blockId. The full `selectionTarget` is included whenever the + * two endpoints refer to different blocks or different point kinds. + */ +function selectionTargetToResolution( + selectionTarget: SelectionTarget, + range: { from: number; to: number }, + text: string, +): TextMutationResolution { + const startPoint = selectionTarget.start; + const endPoint = selectionTarget.end; + + const blockId = blockIdFromPoint(startPoint); + const startOffset = startPoint.kind === 'text' ? startPoint.offset : 0; + const endOffset = endPoint.kind === 'text' && endPoint.blockId === blockId ? endPoint.offset : startOffset; + + const isCrossBlock = + startPoint.kind !== 'text' || + endPoint.kind !== 'text' || + blockIdFromPoint(startPoint) !== blockIdFromPoint(endPoint); + + return { + target: { kind: 'text', blockId, range: { start: startOffset, end: endOffset } }, + range, + text, + ...(isCrossBlock ? { selectionTarget } : undefined), + }; +} + +/** + * Builds a TextMutationResolution directly from the compiled plan's + * CompiledSelectionTarget. This produces correct resolution data + * regardless of how the executor internally represents targets. + */ +function buildSelectionResolutionFromCompiled(compiled: CompiledPlan, stepId: string): TextMutationResolution { + const compiledStep = compiled.mutationSteps.find((s) => s.step.id === stepId); + const target = compiledStep?.targets[0]; + + if (target?.kind === 'selection') { + const sel = target as CompiledSelectionTarget; + return selectionTargetToResolution(sel.normalizedTarget, { from: sel.absFrom, to: sel.absTo }, sel.text); + } + + // Fallback for non-selection targets (ref-based resolution). + if (target?.kind === 'range') { + return { + target: { kind: 'text', blockId: target.blockId, range: { start: target.from, end: target.to } }, + range: { from: target.absFrom, to: target.absTo }, + text: target.text, + }; + } + + return { + target: { kind: 'text', blockId: '', range: { start: 0, end: 0 } }, + range: { from: 0, to: 0 }, + text: '', + }; +} + +/** + * Builds resolution from a step outcome, falling back to compiled target + * data when the outcome doesn't carry resolutions. + */ +function buildSelectionResolutionFromOutcome( + stepOutcome: StepOutcome | undefined, + compiled: CompiledPlan, + stepId: string, +): TextMutationResolution { + // Try plan outcome first — executors may produce detailed resolutions. + if (stepOutcome?.data) { + const data = stepOutcome.data; + + // Prefer selection-aware resolutions when available. + if ( + 'selectionResolutions' in data && + Array.isArray(data.selectionResolutions) && + data.selectionResolutions.length > 0 + ) { + const selRes = data.selectionResolutions[0] as SelectionStepResolution; + return selectionTargetToResolution(selRes.selectionTarget, selRes.range, selRes.text); + } + + // Fall back to plain range resolutions. + if ('resolutions' in data && Array.isArray(data.resolutions) && data.resolutions.length > 0) { + const planRes = data.resolutions[0] as TextMutationResolution; + if (planRes.target?.blockId !== '__selection__') { + return planRes; + } + } + } + + // Fall back to the compiled target data, which is always correct. + return buildSelectionResolutionFromCompiled(compiled, stepId); +} + // --------------------------------------------------------------------------- // Structured content insertion (markdown / html) // --------------------------------------------------------------------------- @@ -988,6 +1166,255 @@ export function replaceStructuredWrapper( return textReceiptToSDReceipt(executeStructuralReplaceWrapper(editor, input, options)); } +/** + * Resolved structural replace locator — contains the primary TextAddress + * (for the engine's target parameter) and metadata about the actual + * replacement scope for accurate receipt resolution. + */ +interface ResolvedStructuralLocator { + /** Primary block TextAddress — always points to the first targeted block. */ + textTarget: TextAddress; + /** + * Pre-resolved PM range spanning the full replacement area. + * Present for SelectionTarget and multi-segment text ref locators. + * When absent, the engine resolves the range from `textTarget`. + */ + resolvedRange?: { from: number; to: number }; + /** + * Effective SelectionTarget describing the actual block-boundary-expanded + * scope of the replacement. Present whenever the replacement spans more + * than one block — whether the input was a SelectionTarget or a multi-block + * ref. Used to populate `selectionTarget` on the receipt. + */ + effectiveSelectionTarget?: SelectionTarget; + /** + * True when the input used a ref-based locator (no caller-supplied target). + * Resolution should omit `requestedTarget` since the TextAddress is synthetic. + */ + isRefBased?: boolean; +} + +/** + * Resolves the target/ref locator from an SDReplaceInput into a + * ResolvedStructuralLocator for the structural replace engine. + * + * Single-block locators (SDAddress, TextAddress, raw nodeId ref) produce + * only a `textTarget`. Multi-block locators (cross-block SelectionTarget, + * multi-segment text refs) also produce a `resolvedRange` spanning the + * full contiguous block range so the engine replaces all covered blocks. + */ +function resolveStructuralLocator(editor: Editor, input: SDReplaceInput): ResolvedStructuralLocator { + const { target, ref } = input; + + if (target !== undefined) { + // SelectionTarget — resolve to absolute positions. + if (target.kind === 'selection') { + const sel = target; + const resolved = resolveSelectionTarget(editor, sel); + + // Expand to full block boundaries for structural replace. + const index = getBlockIndex(editor); + const expanded = expandToBlockBoundaries(index, resolved.absFrom, resolved.absTo); + + const textTarget: TextAddress = { + kind: 'text', + blockId: expanded.firstBlock.nodeId, + range: { start: 0, end: 0 }, + }; + + return { + textTarget, + resolvedRange: { from: expanded.blockFrom, to: expanded.blockTo }, + effectiveSelectionTarget: buildEffectiveSelectionTarget(expanded), + }; + } + // SDAddress | TextAddress — existing bridge (single block). + return { textTarget: narrowToTextAddress(target) }; + } + + if (ref !== undefined) { + // V3 text ref — decode payload and resolve blocks. + if (ref.startsWith('text:')) { + const result = resolveTextRefLocator(editor, ref); + return { ...result, isRefBased: true }; + } + // Raw nodeId ref — target the full block (single block). + return { + textTarget: { kind: 'text', blockId: ref, range: { start: 0, end: 0 } }, + isRefBased: true, + }; + } + + throw new DocumentApiAdapterError('INVALID_TARGET', 'Structural replace requires either target or ref.'); +} + +/** + * Decodes a V3 text ref and resolves all segments to a spanning block range. + * Single-segment refs resolve as single-block; multi-segment refs produce + * a resolvedRange spanning from the first to last segment's block. + */ +function resolveTextRefLocator(editor: Editor, ref: string): ResolvedStructuralLocator { + let payload: { segments?: Array<{ blockId: string }> }; + try { + payload = JSON.parse(atob(ref.slice(5))); + } catch { + throw new DocumentApiAdapterError('INVALID_TARGET', `Cannot decode text ref for structural replace: ${ref}`); + } + + const segments = payload?.segments; + if (!Array.isArray(segments) || segments.length === 0) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + 'Text ref does not contain valid segments for structural replace.', + ); + } + + const firstBlockId = segments[0].blockId; + if (typeof firstBlockId !== 'string' || firstBlockId.length === 0) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + 'Text ref does not contain a valid blockId for structural replace.', + ); + } + + const textTarget: TextAddress = { kind: 'text', blockId: firstBlockId, range: { start: 0, end: 0 } }; + + // Single-segment ref → single-block replacement. + if (segments.length === 1) { + return { textTarget }; + } + + // Multi-segment ref → resolve all blocks and span the range. + const index = getBlockIndex(editor); + let rangeFrom = Infinity; + let rangeTo = -Infinity; + let firstCandidate: BlockCandidate | undefined; + let lastCandidate: BlockCandidate | undefined; + + for (const seg of segments) { + if (typeof seg.blockId !== 'string') continue; + try { + const block = findBlockByNodeIdOnly(index, seg.blockId); + if (block.pos < rangeFrom) { + rangeFrom = block.pos; + firstCandidate = block; + } + if (block.end > rangeTo) { + rangeTo = block.end; + lastCandidate = block; + } + } catch { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Cannot resolve text ref segment block "${seg.blockId}".`); + } + } + + // Build effective SelectionTarget for multi-block receipt metadata. + const effectiveSelectionTarget: SelectionTarget | undefined = + firstCandidate && lastCandidate && firstCandidate.nodeId !== lastCandidate.nodeId + ? { + kind: 'selection', + start: buildSelectionPoint(firstCandidate, 'start'), + end: buildSelectionPoint(lastCandidate, 'end'), + } + : undefined; + + return { textTarget, resolvedRange: { from: rangeFrom, to: rangeTo }, effectiveSelectionTarget }; +} + +/** Result of expanding a PM range to full block boundaries. */ +interface ExpandedBlockRange { + blockFrom: number; + blockTo: number; + /** The first block candidate in the expanded range. */ + firstBlock: BlockCandidate; + /** The last block candidate in the expanded range. */ + lastBlock: BlockCandidate; +} + +/** + * Expands a PM position range to encompass full block boundaries. + * Finds the first block whose range intersects `absFrom` and the last + * block whose range intersects `absTo`, then returns their outer boundaries + * plus the block IDs needed for receipt metadata. + */ +function expandToBlockBoundaries(index: BlockIndex, absFrom: number, absTo: number): ExpandedBlockRange { + let blockFrom = absFrom; + let blockTo = absTo; + let firstBlock: BlockCandidate | undefined; + let lastBlock: BlockCandidate | undefined; + + for (const candidate of index.candidates) { + // Skip non-overlapping blocks. + if (candidate.end <= absFrom || candidate.pos >= absTo) continue; + if (candidate.pos <= blockFrom) { + blockFrom = candidate.pos; + firstBlock = candidate; + } + if (candidate.end >= blockTo) { + blockTo = candidate.end; + lastBlock = candidate; + } + } + + if (!firstBlock || !lastBlock) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Cannot resolve block boundaries for the target range.'); + } + + return { blockFrom, blockTo, firstBlock, lastBlock }; +} + +/** Node types valid as nodeEdge selection anchors — kept in sync with SELECTION_EDGE_NODE_TYPES in document-api. */ +const VALID_EDGE_NODE_TYPES: ReadonlySet = new Set([ + 'paragraph', + 'heading', + 'table', + 'tableOfContents', + 'sdt', +]); + +/** + * Builds a SelectionPoint for a block candidate. + * Text blocks (paragraph, heading) produce `kind: 'text'` points. + * Non-text blocks (table, tableOfContents, sdt) produce `kind: 'nodeEdge'` points. + */ +function buildSelectionPoint(candidate: BlockCandidate, edge: 'start' | 'end'): SelectionPoint { + if (isTextBlockCandidate(candidate)) { + return { + kind: 'text', + blockId: candidate.nodeId, + offset: edge === 'start' ? 0 : candidate.node.textContent.length, + }; + } + if (!VALID_EDGE_NODE_TYPES.has(candidate.nodeType)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Block type "${candidate.nodeType}" is not valid as a selection edge anchor.`, + ); + } + return { + kind: 'nodeEdge', + node: { + kind: 'block', + nodeType: candidate.nodeType as SelectionEdgeNodeType, + nodeId: candidate.nodeId, + }, + edge: edge === 'start' ? 'before' : 'after', + }; +} + +/** + * Builds an effective SelectionTarget describing the full block-boundary scope + * of a structural replacement. Returns undefined for single-block ranges. + */ +function buildEffectiveSelectionTarget(expanded: ExpandedBlockRange): SelectionTarget | undefined { + if (expanded.firstBlock.nodeId === expanded.lastBlock.nodeId) return undefined; + return { + kind: 'selection', + start: buildSelectionPoint(expanded.firstBlock, 'start'), + end: buildSelectionPoint(expanded.lastBlock, 'end'), + }; +} + /** * Handles structural replace (SDFragment content). * Wraps the structural replace engine to produce a TextMutationReceipt. @@ -997,34 +1424,55 @@ function executeStructuralReplaceWrapper( input: SDReplaceInput, options?: MutationOptions, ): TextMutationReceipt { - const { content, target, nestingPolicy } = input; + const { content, nestingPolicy } = input; const mode = options?.changeMode ?? 'direct'; - // Narrow SDAddress | TextAddress → TextAddress for the current adapter layer. - const textTarget = narrowToTextAddress(target); + const locator = resolveStructuralLocator(editor, input); + const { textTarget, resolvedRange: locatorRange, effectiveSelectionTarget, isRefBased } = locator; - // Block-level resolution for metadata — uses the same block lookup as the engine. - // This supports non-text blocks (tables, images) that resolveTextTarget would miss. - let resolvedBlock; - try { - resolvedBlock = resolveStructuralReplaceTarget(editor, textTarget); - } catch (err) { - if (err instanceof DocumentApiAdapterError) throw err; - throw new DocumentApiAdapterError( - 'TARGET_NOT_FOUND', - `Cannot resolve replace target for block "${textTarget.blockId}".`, - ); + // Resolve the effective replacement range. + // For multi-block locators, use the pre-resolved range. Otherwise, + // fall back to single-block resolution through the structural engine. + let effectiveRange: { from: number; to: number }; + if (locatorRange) { + effectiveRange = locatorRange; + } else { + let resolvedBlock; + try { + resolvedBlock = resolveStructuralReplaceTarget(editor, textTarget); + } catch (err) { + if (err instanceof DocumentApiAdapterError) throw err; + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Cannot resolve replace target for block "${textTarget.blockId}".`, + ); + } + effectiveRange = { from: resolvedBlock.from, to: resolvedBlock.to }; } - const resolvedRange = { from: resolvedBlock.from, to: resolvedBlock.to }; - // Snapshot the text currently covered by the target block (contract: non-empty for non-collapsed ranges). - const coveredText = editor.state.doc.textBetween(resolvedBlock.from, resolvedBlock.to, '\n', '\ufffc'); - const resolution = buildTextMutationResolution({ - requestedTarget: textTarget, - target: textTarget, - range: resolvedRange, - text: coveredText, - }); + // Snapshot the text currently covered by the target range. + const coveredText = editor.state.doc.textBetween(effectiveRange.from, effectiveRange.to, '\n', '\ufffc'); + + // Build resolution from the effective (expanded) selection target when present. + // This covers both SelectionTarget inputs and multi-block ref inputs — both + // produce an effectiveSelectionTarget describing the actual block-boundary scope. + // For single-block inputs, fall back to the direct TextAddress resolution. + let resolution: TextMutationResolution; + if (effectiveSelectionTarget) { + resolution = selectionTargetToResolution(effectiveSelectionTarget, effectiveRange, coveredText); + } else { + resolution = buildTextMutationResolution({ + // Omit requestedTarget for ref-based calls — the textTarget is synthetic. + requestedTarget: isRefBased ? undefined : textTarget, + target: textTarget, + range: effectiveRange, + text: coveredText, + }); + } + + // Enforce expectedRevision even on dry-run — callers need to know if the + // document has drifted since their last query. + checkRevision(editor, options?.expectedRevision); try { // Dry-run: run full structural engine validation (target, materialization, nesting), @@ -1036,6 +1484,7 @@ function executeStructuralReplaceWrapper( nestingPolicy, changeMode: mode, dryRun: true, + resolvedRange: locatorRange, }); return { success: true, resolution }; } @@ -1048,6 +1497,7 @@ function executeStructuralReplaceWrapper( content, nestingPolicy, changeMode: mode, + resolvedRange: locatorRange, }); return result.success; }, diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts index 75073c184c..2caa205892 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts @@ -62,6 +62,9 @@ export function previewPlan(editor: Editor, input: MutationsPreviewInput): Mutat if ('spanResolutions' in outcome.data && outcome.data.spanResolutions?.length) { preview.spanResolutions = outcome.data.spanResolutions; } + if ('selectionResolutions' in outcome.data && outcome.data.selectionResolutions?.length) { + preview.selectionResolutions = outcome.data.selectionResolutions; + } } stepPreviews.push(preview); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts index 20f193099b..86aeb612dd 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts @@ -20,6 +20,7 @@ import type { MatchRun, CardinalityRequirement, TextAddress, + SelectionTarget, HighlightRange, InlineAnchor, PageInfo, @@ -51,7 +52,7 @@ import { readTranslatedLinkedStyles } from '../../core/parts/adapters/styles-rea // V3 ref encoding (D6) // --------------------------------------------------------------------------- -interface TextRefV3 { +export interface TextRefV3 { v: 3; rev: string; matchId: string; @@ -61,10 +62,47 @@ interface TextRefV3 { runIndex?: number; } -function encodeV3Ref(payload: TextRefV3): string { +export function encodeV3Ref(payload: TextRefV3): string { return `text:${btoa(JSON.stringify(payload))}`; } +// --------------------------------------------------------------------------- +// SelectionTarget builder — mutation-ready target from match blocks +// --------------------------------------------------------------------------- + +/** + * Builds a canonical `SelectionTarget` from completed match blocks. + * + * Uses the first block's start and the last block's end to form a + * contiguous selection spanning all matched blocks. + */ +function buildSelectionTargetFromBlocks(blocks: MatchBlock[]): SelectionTarget { + const first = blocks[0]!; + const last = blocks[blocks.length - 1]!; + + return { + kind: 'selection', + start: { kind: 'text', blockId: first.blockId, offset: first.range.start }, + end: { kind: 'text', blockId: last.blockId, offset: last.range.end }, + }; +} + +/** + * Builds a canonical `SelectionTarget` from raw text ranges. + * + * Used by the legacy find adapter which doesn't build match blocks. + */ +export function buildSelectionTargetFromTextRanges(textRanges: TextAddress[]): SelectionTarget { + const first = textRanges[0]!; + const last = textRanges[textRanges.length - 1]!; + + return { + kind: 'selection', + start: { kind: 'text', blockId: first.blockId, offset: first.range.start }, + end: { kind: 'text', blockId: last.blockId, offset: last.range.end }, + }; +} + // --------------------------------------------------------------------------- // Block/run builders (D4, D5) // --------------------------------------------------------------------------- @@ -478,6 +516,7 @@ export function queryMatchAdapter(editor: Editor, input: QueryMatchInput): Query handle: buildResolvedHandle(ref, 'ephemeral', 'text'), matchKind: 'text', address: raw.address, + target: buildSelectionTargetFromBlocks(blocks), snippet: snippetResult?.snippet ?? '', highlightRange: snippetResult?.highlightRange ?? { start: 0, end: 0 }, blocks: blocks as [MatchBlock, ...MatchBlock[]], diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts index 138615ddf0..270289ddd4 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts @@ -16,6 +16,7 @@ import type { TextStepData, TextStepResolution, SpanStepResolution, + SelectionStepResolution, TextRewriteStep, TextInsertStep, TextDeleteStep, @@ -32,6 +33,7 @@ import type { CompiledTarget, CompiledRangeTarget, CompiledSpanTarget, + CompiledSelectionTarget, ExecuteContext, } from './executor-registry.types.js'; import { registerStepExecutor } from './executor-registry.js'; @@ -96,17 +98,42 @@ import { // Target partitioning // --------------------------------------------------------------------------- +/** + * Converts a CompiledSelectionTarget to a CompiledRangeTarget for executor + * dispatch. Range executors only use absFrom/absTo for PM operations. + */ +function selectionTargetToRange(t: CompiledSelectionTarget): CompiledRangeTarget { + const startPoint = t.normalizedTarget.start; + const blockId = startPoint.kind === 'text' ? startPoint.blockId : '__selection__'; + return { + kind: 'range', + stepId: t.stepId, + op: t.op, + blockId, + from: 0, + to: t.absTo - t.absFrom, + absFrom: t.absFrom, + absTo: t.absTo, + text: t.text, + marks: [], + capturedStyle: t.capturedStyle, + }; +} + function partitionTargets(targets: CompiledTarget[]): { range: CompiledRangeTarget[]; span: CompiledSpanTarget[]; + selection: CompiledSelectionTarget[]; } { const range: CompiledRangeTarget[] = []; const span: CompiledSpanTarget[] = []; + const selection: CompiledSelectionTarget[] = []; for (const t of targets) { if (t.kind === 'range') range.push(t); + else if (t.kind === 'selection') selection.push(t); else span.push(t); } - return { range, span }; + return { range, span, selection }; } // --------------------------------------------------------------------------- @@ -144,6 +171,14 @@ function buildSpanResolution(target: CompiledSpanTarget): SpanStepResolution { }; } +function buildSelectionResolution(target: CompiledSelectionTarget): SelectionStepResolution { + return { + selectionTarget: target.normalizedTarget, + range: { from: target.absFrom, to: target.absTo }, + text: target.text, + }; +} + // --------------------------------------------------------------------------- // Unified step execution — dispatches range and span targets // --------------------------------------------------------------------------- @@ -176,10 +211,11 @@ function executeTextStep( rangeExecutor: RangeExecutorFn, spanExecutor?: SpanExecutorFn, ): StepOutcome { - const { range, span } = partitionTargets(targets); + const { range, span, selection } = partitionTargets(targets); let overallChanged = false; const resolutions: TextStepResolution[] = []; const spanResolutions: SpanStepResolution[] = []; + const selectionResolutions: SelectionStepResolution[] = []; // Execute range targets in document order for (const target of sortRangeTargets(range)) { @@ -188,6 +224,15 @@ function executeTextStep( if (changed) overallChanged = true; } + // Execute selection targets — convert to range for the executor, but + // produce proper SelectionStepResolution instead of bogus TextStepResolution. + for (const selTarget of selection) { + selectionResolutions.push(buildSelectionResolution(selTarget)); + const rangeTarget = selectionTargetToRange(selTarget); + const { changed } = rangeExecutor(ctx.editor, ctx.tr, rangeTarget, step, ctx.mapping); + if (changed) overallChanged = true; + } + // Execute span targets for (const target of span) { spanResolutions.push(buildSpanResolution(target)); @@ -203,6 +248,7 @@ function executeTextStep( domain: 'text', resolutions, ...(spanResolutions.length > 0 ? { spanResolutions } : {}), + ...(selectionResolutions.length > 0 ? { selectionResolutions } : {}), }; return { stepId: step.id, op: step.op, effect, matchCount: targets.length, data }; diff --git a/packages/super-editor/src/document-api-adapters/structural-write-engine/index.ts b/packages/super-editor/src/document-api-adapters/structural-write-engine/index.ts index 95ce4fc0ed..e70c1ac5f4 100644 --- a/packages/super-editor/src/document-api-adapters/structural-write-engine/index.ts +++ b/packages/super-editor/src/document-api-adapters/structural-write-engine/index.ts @@ -47,6 +47,12 @@ export interface StructuralReplaceOptions { changeMode?: 'direct' | 'tracked'; /** When true, runs all validation (target resolution, materialization, nesting policy) but skips the transaction dispatch. */ dryRun?: boolean; + /** + * Pre-resolved replacement range. When present, skips single-block target + * resolution and uses this range directly. Used by the wrapper for + * multi-block locators (SelectionTarget spanning blocks, multi-segment refs). + */ + resolvedRange?: { from: number; to: number }; } /** Result of a structural write operation. */ @@ -111,11 +117,14 @@ export function executeStructuralInsert(editor: Editor, options: StructuralInser * (from pos to pos + nodeSize) is replaced, not just its text content. */ export function executeStructuralReplace(editor: Editor, options: StructuralReplaceOptions): StructuralWriteResult { - const { content, nestingPolicy, target, changeMode, dryRun } = options; + const { content, nestingPolicy, target, changeMode, dryRun, resolvedRange } = options; const schema = editor.state.schema; - // 1. Resolve target range (full block node range) - const resolved = resolveReplaceTarget(editor, target); + // 1. Resolve target range — use pre-resolved range for multi-block locators, + // otherwise resolve from the single-block TextAddress target. + const resolved = resolvedRange + ? { from: resolvedRange.from, to: resolvedRange.to, effectiveTarget: target } + : resolveReplaceTarget(editor, target); // 2. Validate section references in the fragment validateSectionReferences(editor, content); diff --git a/packages/super-editor/src/document-api-adapters/structural-write-engine/structural-write-engine.test.ts b/packages/super-editor/src/document-api-adapters/structural-write-engine/structural-write-engine.test.ts index b88abbd17a..c447fbef16 100644 --- a/packages/super-editor/src/document-api-adapters/structural-write-engine/structural-write-engine.test.ts +++ b/packages/super-editor/src/document-api-adapters/structural-write-engine/structural-write-engine.test.ts @@ -10,7 +10,7 @@ import { executeStructuralInsert, executeStructuralReplace, materializeFragment import { enforceNestingPolicy } from './nesting-guard.js'; import { validateDocumentFragment } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; -import type { SDFragment } from '@superdoc/document-api'; +import type { SDFragment, SelectionTarget, SDReplaceInput } from '@superdoc/document-api'; let docData: Awaited>; @@ -1065,3 +1065,348 @@ describe('enforceNestingPolicy — SDM/1 kind dispatch', () => { } }); }); + +// --------------------------------------------------------------------------- +// Multi-block structural replace (SelectionTarget, ref, receipt accuracy) +// --------------------------------------------------------------------------- + +describe('replaceStructuredWrapper — multi-block and locator forms', () => { + /** + * Seeds N paragraphs and returns their blockIds in document order. + */ + function seedParagraphs(texts: string[]): string[] { + const ids: string[] = []; + for (const text of texts) { + const seed = executeStructuralInsert(editor, { + content: { type: 'paragraph', content: [{ type: 'text', text }] }, + }); + ids.push(seed.insertedBlockIds[0]!); + } + return ids; + } + + /** + * Builds a SelectionTarget spanning from the start of blockA to the end of blockB. + */ + function spanSelection( + startBlockId: string, + startOffset: number, + endBlockId: string, + endOffset: number, + ): SelectionTarget { + return { + kind: 'selection', + start: { kind: 'text', blockId: startBlockId, offset: startOffset }, + end: { kind: 'text', blockId: endBlockId, offset: endOffset }, + }; + } + + it('replaces multiple blocks when target is a cross-block SelectionTarget', () => { + const ids = seedParagraphs(['alpha', 'bravo', 'charlie']); + + const target = spanSelection(ids[0]!, 0, ids[2]!, 7); + const result = replaceStructuredWrapper(editor, { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'merged' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('merged'); + expect(editor.state.doc.textContent).not.toContain('alpha'); + expect(editor.state.doc.textContent).not.toContain('bravo'); + expect(editor.state.doc.textContent).not.toContain('charlie'); + }); + + it('includes selectionTarget in receipt for cross-block SelectionTarget', () => { + const ids = seedParagraphs(['first', 'second']); + + const target = spanSelection(ids[0]!, 0, ids[1]!, 6); + const result = replaceStructuredWrapper(editor, { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'combined' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(result.resolution).toBeDefined(); + // Cross-block receipt should carry selectionTarget. + expect(result.resolution!.selectionTarget).toBeDefined(); + expect(result.resolution!.selectionTarget!.kind).toBe('selection'); + }); + + it('replaces a single block via raw nodeId ref', () => { + const ids = seedParagraphs(['ref-target']); + + const result = replaceStructuredWrapper(editor, { + ref: ids[0]!, + content: { type: 'paragraph', content: [{ type: 'text', text: 'ref-replaced' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('ref-replaced'); + expect(editor.state.doc.textContent).not.toContain('ref-target'); + }); + + it('omits requestedTarget for ref-based structural replace receipts', () => { + const ids = seedParagraphs(['no-requested']); + + const result = replaceStructuredWrapper(editor, { + ref: ids[0]!, + content: { type: 'paragraph', content: [{ type: 'text', text: 'done' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(result.resolution).toBeDefined(); + // Ref-based calls should NOT report a fabricated requestedTarget. + expect(result.resolution!.requestedTarget).toBeUndefined(); + }); + + it('replaces a single block via single-block SelectionTarget (no selectionTarget in receipt)', () => { + const ids = seedParagraphs(['solo']); + + const target = spanSelection(ids[0]!, 0, ids[0]!, 4); + const result = replaceStructuredWrapper(editor, { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'replaced-solo' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('replaced-solo'); + // Single-block selection: selectionTarget should be absent. + expect(result.resolution!.selectionTarget).toBeUndefined(); + }); + + it('throws INVALID_TARGET when neither target nor ref is provided', () => { + expect(() => + replaceStructuredWrapper(editor, { + content: { type: 'paragraph', content: [{ type: 'text', text: 'orphan' }] }, + } as SDReplaceInput), + ).toThrow(DocumentApiAdapterError); + }); + + it('supports dry-run for cross-block SelectionTarget without mutating', () => { + const ids = seedParagraphs(['keep-a', 'keep-b']); + const textBefore = editor.state.doc.textContent; + + const target = spanSelection(ids[0]!, 0, ids[1]!, 6); + const result = replaceStructuredWrapper( + editor, + { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'gone' }] }, + } as SDReplaceInput, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toBe(textBefore); + }); + + it('receipt reflects expanded block boundaries for partial-offset cross-block selection', () => { + const ids = seedParagraphs(['hello', 'world']); + + // Partial selection: offset 2 in first block, offset 3 in second block. + // Structural replace expands to full block boundaries. + const target = spanSelection(ids[0]!, 2, ids[1]!, 3); + const result = replaceStructuredWrapper(editor, { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'expanded' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + // Both blocks should be fully replaced despite partial offsets. + expect(editor.state.doc.textContent).toContain('expanded'); + expect(editor.state.doc.textContent).not.toContain('hello'); + expect(editor.state.doc.textContent).not.toContain('world'); + + // The effective selectionTarget should describe full block boundaries + // (offset 0 on first block, full length on last block), not the + // original partial offsets. + const sel = result.resolution!.selectionTarget!; + expect(sel).toBeDefined(); + expect(sel.kind).toBe('selection'); + const startPt = sel.start as { kind: 'text'; blockId: string; offset: number }; + const endPt = sel.end as { kind: 'text'; blockId: string; offset: number }; + expect(startPt.offset).toBe(0); + expect(endPt.offset).toBe(5); // 'world'.length + }); + + it('receipt reflects expanded block boundary for partial single-block selection', () => { + const ids = seedParagraphs(['abcdef']); + + // Partial single-block selection: offset 2 to 4. + // Structural replace expands to the full block. + const target = spanSelection(ids[0]!, 2, ids[0]!, 4); + const result = replaceStructuredWrapper(editor, { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'full' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('full'); + expect(editor.state.doc.textContent).not.toContain('abcdef'); + + // Single-block: no selectionTarget needed. + expect(result.resolution!.selectionTarget).toBeUndefined(); + // The target should report full block (offset 0), not the partial offset. + expect(result.resolution!.target.nodeId).toBe(ids[0]); + }); + + it('multi-segment text: ref replaces all segments and includes selectionTarget', () => { + const ids = seedParagraphs(['seg-one', 'seg-two', 'seg-three']); + + // Build a synthetic multi-segment V3 text ref. + const refPayload = { + v: 3, + rev: 'ignored', // structural replace does not check ref revision + scope: 'body', + segments: [ + { blockId: ids[0]!, blockIndex: 0, runIndex: 0, from: 0, to: 7 }, + { blockId: ids[1]!, blockIndex: 1, runIndex: 0, from: 0, to: 7 }, + { blockId: ids[2]!, blockIndex: 2, runIndex: 0, from: 0, to: 9 }, + ], + }; + const ref = `text:${btoa(JSON.stringify(refPayload))}`; + + const result = replaceStructuredWrapper(editor, { + ref, + content: { type: 'paragraph', content: [{ type: 'text', text: 'all-merged' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('all-merged'); + expect(editor.state.doc.textContent).not.toContain('seg-one'); + expect(editor.state.doc.textContent).not.toContain('seg-two'); + expect(editor.state.doc.textContent).not.toContain('seg-three'); + + // Multi-block ref: receipt should carry selectionTarget. + expect(result.resolution!.selectionTarget).toBeDefined(); + expect(result.resolution!.selectionTarget!.kind).toBe('selection'); + // Should NOT have a fabricated requestedTarget. + expect(result.resolution!.requestedTarget).toBeUndefined(); + }); + + it('single-segment text: ref replaces one block without selectionTarget', () => { + const ids = seedParagraphs(['only-one']); + + const refPayload = { + v: 3, + rev: 'ignored', + scope: 'body', + segments: [{ blockId: ids[0]!, blockIndex: 0, runIndex: 0, from: 0, to: 8 }], + }; + const ref = `text:${btoa(JSON.stringify(refPayload))}`; + + const result = replaceStructuredWrapper(editor, { + ref, + content: { type: 'paragraph', content: [{ type: 'text', text: 'single-ref' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('single-ref'); + expect(editor.state.doc.textContent).not.toContain('only-one'); + + // Single-segment: no selectionTarget. + expect(result.resolution!.selectionTarget).toBeUndefined(); + }); + + it('receipt emits nodeEdge endpoint when replacement boundary lands on a table', () => { + // Seed a paragraph followed by a table. + const paraIds = seedParagraphs(['before-table']); + const tableSeed = executeStructuralInsert(editor, { + content: { + type: 'table', + rows: [ + { + type: 'tableRow', + cells: [{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'cell' }] }] }], + }, + ], + }, + }); + const tableId = tableSeed.insertedBlockIds[0]!; + + // Cross-block SelectionTarget: text start on paragraph, nodeEdge end on table. + const target: SelectionTarget = { + kind: 'selection', + start: { kind: 'text', blockId: paraIds[0]!, offset: 0 }, + end: { kind: 'nodeEdge', node: { kind: 'block', nodeType: 'table', nodeId: tableId }, edge: 'after' }, + }; + + const result = replaceStructuredWrapper(editor, { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'replaced-both' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('replaced-both'); + expect(editor.state.doc.textContent).not.toContain('before-table'); + expect(editor.state.doc.textContent).not.toContain('cell'); + + // The effective selectionTarget should use kind:'text' for the paragraph + // and kind:'nodeEdge' for the table. + const sel = result.resolution!.selectionTarget!; + expect(sel).toBeDefined(); + expect(sel.kind).toBe('selection'); + expect(sel.start.kind).toBe('text'); + expect(sel.end.kind).toBe('nodeEdge'); + const endPt = sel.end as { + kind: 'nodeEdge'; + node: { kind: 'block'; nodeType: string; nodeId: string }; + edge: string; + }; + expect(endPt.node.nodeType).toBe('table'); + expect(endPt.edge).toBe('after'); + }); + + it('receipt emits nodeEdge start when first boundary block is a table', () => { + // Seed a table, then explicitly place a paragraph after it (default insert + // targets the last text block, which would place it before the table). + const tableSeed = executeStructuralInsert(editor, { + content: { + type: 'table', + rows: [ + { + type: 'tableRow', + cells: [ + { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'tdata' }] }] }, + ], + }, + ], + }, + }); + const tableId = tableSeed.insertedBlockIds[0]!; + + // Place tail paragraph explicitly after the table. + const tailSeed = executeStructuralInsert(editor, { + content: { type: 'paragraph', content: [{ type: 'text', text: 'tail-text' }] }, + target: { kind: 'text', blockId: tableId, range: { start: 0, end: 0 } }, + placement: 'after', + }); + const tailId = tailSeed.insertedBlockIds[0]!; + + // Cross-block SelectionTarget: nodeEdge start on table, text end on tail paragraph. + const target: SelectionTarget = { + kind: 'selection', + start: { kind: 'nodeEdge', node: { kind: 'block', nodeType: 'table', nodeId: tableId }, edge: 'before' }, + end: { kind: 'text', blockId: tailId, offset: 9 }, + }; + + const result = replaceStructuredWrapper(editor, { + target, + content: { type: 'paragraph', content: [{ type: 'text', text: 'replaced-all' }] }, + } as SDReplaceInput); + + expect(result.success).toBe(true); + expect(editor.state.doc.textContent).toContain('replaced-all'); + + // The effective selectionTarget should use kind:'nodeEdge' for the table + // and kind:'text' for the paragraph. + const sel = result.resolution!.selectionTarget!; + expect(sel).toBeDefined(); + expect(sel.start.kind).toBe('nodeEdge'); + expect(sel.end.kind).toBe('text'); + const startPt = sel.start as { kind: 'nodeEdge'; node: { kind: 'block'; nodeType: string }; edge: string }; + expect(startPt.node.nodeType).toBe('table'); + expect(startPt.edge).toBe('before'); + }); +}); From d03f38556def893ee86a315c67e7dfc16b1e0abc Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 13 Mar 2026 10:18:45 -0700 Subject: [PATCH 2/9] feat(document-api): add ranges.resolve operation for deterministic range construction --- .../document-api/available-operations.mdx | 2 + .../reference/_generated-manifest.json | 11 +- .../reference/capabilities/get.mdx | 49 ++ apps/docs/document-api/reference/index.mdx | 7 + .../document-api/reference/ranges/index.mdx | 18 + .../document-api/reference/ranges/resolve.mdx | 362 +++++++++ .../src/contract/contract.test.ts | 1 + .../src/contract/operation-definitions.ts | 20 +- .../src/contract/operation-registry.ts | 4 + .../src/contract/reference-doc-map.ts | 5 + packages/document-api/src/contract/schemas.ts | 80 ++ packages/document-api/src/index.ts | 32 + packages/document-api/src/invoke/invoke.ts | 3 + packages/document-api/src/ranges/index.ts | 12 + .../document-api/src/ranges/ranges.types.ts | 119 +++ .../document-api/src/ranges/resolve.test.ts | 272 +++++++ packages/document-api/src/ranges/resolve.ts | 119 +++ packages/document-api/src/types/address.ts | 4 +- .../assemble-adapters.ts | 4 + .../helpers/range-resolver.test.ts | 766 ++++++++++++++++++ .../helpers/range-resolver.ts | 462 +++++++++++ 21 files changed, 2348 insertions(+), 4 deletions(-) create mode 100644 apps/docs/document-api/reference/ranges/index.mdx create mode 100644 apps/docs/document-api/reference/ranges/resolve.mdx create mode 100644 packages/document-api/src/ranges/index.ts create mode 100644 packages/document-api/src/ranges/ranges.types.ts create mode 100644 packages/document-api/src/ranges/resolve.test.ts create mode 100644 packages/document-api/src/ranges/resolve.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/range-resolver.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 35a32d2c77..71dd76fbda 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -37,6 +37,7 @@ Use the tables below to see what operations are available and where each one is | Paragraph Formatting | 17 | 0 | 17 | [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) | | Sections | 18 | 0 | 18 | [Reference](/document-api/reference/sections/index) | | Styles | 1 | 0 | 1 | [Reference](/document-api/reference/styles/index) | | Table of Authorities | 11 | 0 | 11 | [Reference](/document-api/reference/authorities/index) | @@ -321,6 +322,7 @@ Use the tables below to see what operations are available and where each one is | 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) | +| editor.doc.ranges.resolve(...) | [`ranges.resolve`](/document-api/reference/ranges/resolve) | | editor.doc.sections.list(...) | [`sections.list`](/document-api/reference/sections/list) | | editor.doc.sections.get(...) | [`sections.get`](/document-api/reference/sections/get) | | editor.doc.sections.setBreakType(...) | [`sections.setBreakType`](/document-api/reference/sections/set-break-type) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 61731c51a7..7588eb7b15 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -307,6 +307,8 @@ "apps/docs/document-api/reference/mutations/preview.mdx", "apps/docs/document-api/reference/query/index.mdx", "apps/docs/document-api/reference/query/match.mdx", + "apps/docs/document-api/reference/ranges/index.mdx", + "apps/docs/document-api/reference/ranges/resolve.mdx", "apps/docs/document-api/reference/replace.mdx", "apps/docs/document-api/reference/sections/clear-header-footer-ref.mdx", "apps/docs/document-api/reference/sections/clear-page-borders.mdx", @@ -937,8 +939,15 @@ ], "pagePath": "apps/docs/document-api/reference/authorities/index.mdx", "title": "Table of Authorities" + }, + { + "aliasMemberPaths": [], + "key": "ranges", + "operationIds": ["ranges.resolve"], + "pagePath": "apps/docs/document-api/reference/ranges/index.mdx", + "title": "Ranges" } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "884de1988dd0626237a07743631deedd011a76154bcdd46d931d809d725c0804" + "sourceHash": "08748422ca56636a690f6224f369929364172c120c1113343f06de4f1f4e1e14" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index a42dcda29e..f7d0dfe459 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -1682,6 +1682,11 @@ _No fields._ | `operations.query.match.dryRun` | boolean | yes | | | `operations.query.match.reasons` | enum[] | no | | | `operations.query.match.tracked` | boolean | yes | | +| `operations.ranges.resolve` | object | yes | | +| `operations.ranges.resolve.available` | boolean | yes | | +| `operations.ranges.resolve.dryRun` | boolean | yes | | +| `operations.ranges.resolve.reasons` | enum[] | no | | +| `operations.ranges.resolve.tracked` | boolean | yes | | | `operations.replace` | object | yes | | | `operations.replace.available` | boolean | yes | | | `operations.replace.dryRun` | boolean | yes | | @@ -4629,6 +4634,14 @@ _No fields._ ], "tracked": true }, + "ranges.resolve": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "replace": { "available": true, "dryRun": true, @@ -16788,6 +16801,41 @@ _No fields._ ], "type": "object" }, + "ranges.resolve": { + "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" + }, "replace": { "additionalProperties": false, "properties": { @@ -19622,6 +19670,7 @@ _No fields._ "trackChanges.get", "trackChanges.decide", "query.match", + "ranges.resolve", "mutations.preview", "mutations.apply", "capabilities.get", diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 00f19bd0ed..a889f4d3e1 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -49,6 +49,7 @@ Document API is currently alpha and subject to breaking changes. | Fields | 5 | 0 | 5 | [Open](/document-api/reference/fields/index) | | Citations | 15 | 0 | 15 | [Open](/document-api/reference/citations/index) | | Table of Authorities | 11 | 0 | 11 | [Open](/document-api/reference/authorities/index) | +| Ranges | 1 | 0 | 1 | [Open](/document-api/reference/ranges/index) | ## Available operations @@ -558,3 +559,9 @@ The tables below are grouped by namespace. | authorities.entries.insert | editor.doc.authorities.entries.insert(...) | Insert a new TA authority entry field at a target location. | | authorities.entries.update | editor.doc.authorities.entries.update(...) | Update the properties of an existing TA authority entry. | | authorities.entries.remove | editor.doc.authorities.entries.remove(...) | Remove a TA authority entry field from the document. | + +#### Ranges + +| Operation | API member path | Description | +| --- | --- | --- | +| ranges.resolve | editor.doc.ranges.resolve(...) | Resolve two explicit anchors into a contiguous document range. Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. Stateless and deterministic. | diff --git a/apps/docs/document-api/reference/ranges/index.mdx b/apps/docs/document-api/reference/ranges/index.mdx new file mode 100644 index 0000000000..b231fc7c38 --- /dev/null +++ b/apps/docs/document-api/reference/ranges/index.mdx @@ -0,0 +1,18 @@ +--- +title: Ranges operations +sidebarTitle: Ranges +description: Ranges operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Deterministic range construction from explicit document anchors. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| ranges.resolve | `ranges.resolve` | No | `idempotent` | No | No | + diff --git a/apps/docs/document-api/reference/ranges/resolve.mdx b/apps/docs/document-api/reference/ranges/resolve.mdx new file mode 100644 index 0000000000..804c775a6e --- /dev/null +++ b/apps/docs/document-api/reference/ranges/resolve.mdx @@ -0,0 +1,362 @@ +--- +title: ranges.resolve +sidebarTitle: ranges.resolve +description: Resolve two explicit anchors into a contiguous document range. Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. Stateless and deterministic. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Resolve two explicit anchors into a contiguous document range. Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. Stateless and deterministic. + +- Operation ID: `ranges.resolve` +- API member path: `editor.doc.ranges.resolve(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ResolveRangeOutput with evaluatedRevision, handle.ref, target (SelectionTarget), and preview metadata. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `end` | object(kind="document") \\| object(kind="point") \\| object(kind="ref") | yes | One of: object(kind="document"), object(kind="point"), object(kind="ref") | +| `expectedRevision` | string | no | | +| `start` | object(kind="document") \\| object(kind="point") \\| object(kind="ref") | yes | One of: object(kind="document"), object(kind="point"), object(kind="ref") | + +### Example request + +```json +{ + "end": { + "edge": "start", + "kind": "document" + }, + "expectedRevision": "rev-001", + "start": { + "edge": "start", + "kind": "document" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `evaluatedRevision` | string | yes | | +| `handle` | object(refStability="ephemeral") | yes | | +| `handle.coversFullTarget` | boolean | yes | | +| `handle.ref` | string \\| null | yes | One of: string, null | +| `handle.refStability` | `"ephemeral"` | yes | Constant: `"ephemeral"` | +| `preview` | object | yes | | +| `preview.blocks` | object[] | yes | | +| `preview.text` | string | yes | | +| `preview.truncated` | boolean | yes | | +| `target` | SelectionTarget | yes | SelectionTarget | +| `target.end` | SelectionPoint | yes | SelectionPoint | +| `target.kind` | `"selection"` | yes | Constant: `"selection"` | +| `target.start` | SelectionPoint | yes | SelectionPoint | + +### Example response + +```json +{ + "evaluatedRevision": "rev-001", + "handle": { + "coversFullTarget": true, + "ref": "handle:abc123", + "refStability": "ephemeral" + }, + "preview": { + "blocks": [ + { + "nodeId": "node-def456", + "nodeType": "paragraph", + "textPreview": "example" + } + ], + "text": "Hello, world.", + "truncated": true + }, + "target": { + "end": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + }, + "kind": "selection", + "start": { + "blockId": "block-abc123", + "kind": "text", + "offset": 0 + } + } +} +``` + +## Pre-apply throws + +- `INVALID_INPUT` +- `INVALID_TARGET` +- `TARGET_NOT_FOUND` +- `INVALID_CONTEXT` +- `REVISION_MISMATCH` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "end": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "enum": [ + "start", + "end" + ] + }, + "kind": { + "const": "document" + } + }, + "required": [ + "kind", + "edge" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "point" + }, + "point": { + "$ref": "#/$defs/SelectionPoint" + } + }, + "required": [ + "kind", + "point" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "boundary": { + "enum": [ + "start", + "end" + ] + }, + "kind": { + "const": "ref" + }, + "ref": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "ref", + "boundary" + ], + "type": "object" + } + ] + }, + "expectedRevision": { + "type": "string" + }, + "start": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "enum": [ + "start", + "end" + ] + }, + "kind": { + "const": "document" + } + }, + "required": [ + "kind", + "edge" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "point" + }, + "point": { + "$ref": "#/$defs/SelectionPoint" + } + }, + "required": [ + "kind", + "point" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "boundary": { + "enum": [ + "start", + "end" + ] + }, + "kind": { + "const": "ref" + }, + "ref": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "ref", + "boundary" + ], + "type": "object" + } + ] + } + }, + "required": [ + "start", + "end" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "evaluatedRevision": { + "type": "string" + }, + "handle": { + "additionalProperties": false, + "properties": { + "coversFullTarget": { + "type": "boolean" + }, + "ref": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "refStability": { + "const": "ephemeral" + } + }, + "required": [ + "ref", + "refStability", + "coversFullTarget" + ], + "type": "object" + }, + "preview": { + "additionalProperties": false, + "properties": { + "blocks": { + "items": { + "additionalProperties": false, + "properties": { + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] + }, + "textPreview": { + "type": "string" + } + }, + "required": [ + "nodeId", + "nodeType", + "textPreview" + ], + "type": "object" + }, + "type": "array" + }, + "text": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "text", + "truncated", + "blocks" + ], + "type": "object" + }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } + }, + "required": [ + "evaluatedRevision", + "handle", + "target", + "preview" + ], + "type": "object" +} +``` + diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index dc1e6a2677..d209f6021c 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -185,6 +185,7 @@ describe('document-api contract catalog', () => { 'fields', 'citations', 'authorities', + 'ranges', ]; for (const id of OPERATION_IDS) { expect(validGroups, `${id} has invalid referenceGroup`).toContain(OPERATION_DEFINITIONS[id].referenceGroup); diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 952e0d4d98..1b29ba994f 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -61,7 +61,8 @@ export type ReferenceGroupKey = | 'captions' | 'fields' | 'citations' - | 'authorities'; + | 'authorities' + | 'ranges'; // --------------------------------------------------------------------------- // Entry shape @@ -1774,6 +1775,23 @@ export const OPERATION_DEFINITIONS = { essential: true, }, + 'ranges.resolve': { + memberPath: 'ranges.resolve', + description: + 'Resolve two explicit anchors into a contiguous document range. Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. Stateless and deterministic.', + expectedResult: + 'Returns a ResolveRangeOutput with evaluatedRevision, handle.ref, target (SelectionTarget), and preview metadata.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: ['INVALID_INPUT', 'INVALID_TARGET', 'TARGET_NOT_FOUND', 'INVALID_CONTEXT', 'REVISION_MISMATCH'], + deterministicTargetResolution: true, + }), + referenceDocPath: 'ranges/resolve.mdx', + referenceGroup: 'ranges', + essential: true, + }, + 'mutations.preview': { memberPath: 'mutations.preview', description: 'Dry-run a mutation plan, returning resolved targets without applying changes.', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index c4353e9150..48bb9a31b4 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -145,6 +145,7 @@ import type { SectionsSetVerticalAlignInput, } from '../sections/sections.types.js'; import type { QueryMatchInput, QueryMatchOutput } from '../types/query-match.types.js'; +import type { ResolveRangeInput, ResolveRangeOutput } from '../ranges/ranges.types.js'; import type { CreateImageInput, CreateImageResult, @@ -776,6 +777,9 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { // --- query.* --- 'query.match': { input: QueryMatchInput; options: never; output: QueryMatchOutput }; + // --- ranges.* --- + 'ranges.resolve': { input: ResolveRangeInput; options: never; output: ResolveRangeOutput }; + // --- mutations.* --- 'mutations.preview': { input: MutationsPreviewInput; options: never; output: MutationsPreviewOutput }; 'mutations.apply': { input: MutationsApplyInput; options: never; output: PlanReceipt }; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index c38578045c..4f219efd91 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -166,6 +166,11 @@ const GROUP_METADATA: Record = { ['atomic', 'changeMode', 'steps'], ); + // --------------------------------------------------------------- + // ranges.resolve schema + // --------------------------------------------------------------- + + const documentEdgeAnchorSchema = objectSchema( + { + kind: { const: 'document' }, + edge: { enum: ['start', 'end'] }, + }, + ['kind', 'edge'], + ); + + const pointAnchorSchema = objectSchema( + { + kind: { const: 'point' }, + point: ref('SelectionPoint'), + }, + ['kind', 'point'], + ); + + const refBoundaryAnchorSchema = objectSchema( + { + kind: { const: 'ref' }, + ref: { type: 'string', minLength: 1 }, + boundary: { enum: ['start', 'end'] }, + }, + ['kind', 'ref', 'boundary'], + ); + + const rangeAnchorSchema: JsonSchema = { + oneOf: [documentEdgeAnchorSchema, pointAnchorSchema, refBoundaryAnchorSchema], + }; + + const rangeBlockPreviewSchema = objectSchema( + { + nodeId: { type: 'string' }, + nodeType: { enum: [...blockNodeTypeValues] }, + textPreview: { type: 'string' }, + }, + ['nodeId', 'nodeType', 'textPreview'], + ); + + const rangePreviewSchema = objectSchema( + { + text: { type: 'string' }, + truncated: { type: 'boolean' }, + blocks: arraySchema(rangeBlockPreviewSchema), + }, + ['text', 'truncated', 'blocks'], + ); + + const resolveRangeOutputSchema = objectSchema( + { + evaluatedRevision: { type: 'string' }, + handle: objectSchema( + { + ref: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + refStability: { const: 'ephemeral' }, + coversFullTarget: { type: 'boolean' }, + }, + ['ref', 'refStability', 'coversFullTarget'], + ), + target: selectionTargetSchema, + preview: rangePreviewSchema, + }, + ['evaluatedRevision', 'handle', 'target', 'preview'], + ); + return { + 'ranges.resolve': { + input: objectSchema( + { + start: rangeAnchorSchema, + end: rangeAnchorSchema, + expectedRevision: { type: 'string' }, + }, + ['start', 'end'], + ), + output: resolveRangeOutputSchema, + }, + 'mutations.preview': { input: mutationsInputSchema, output: objectSchema( diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 46f3ef7d52..3b6b766f2a 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -10,6 +10,18 @@ export * from './capabilities/capabilities.js'; export * from './inline-semantics/index.js'; export type { HistoryAdapter, HistoryApi } from './history/history.js'; export type { SelectionMutationAdapter, SelectionMutationRequest } from './selection-mutation.js'; +export type { + RangeAnchor, + DocumentEdgeAnchor, + PointAnchor, + RefBoundaryAnchor, + ResolveRangeInput, + ResolveRangeOutput, + RangeBlockPreview, + RangePreview, + RangeResolverAdapter, +} from './ranges/index.js'; +export { executeResolveRange } from './ranges/index.js'; export type { HeaderFootersAdapter, HeaderFootersApi } from './header-footers/header-footers.js'; export * from './header-footers/header-footers.types.js'; export type { ClearContentAdapter, ClearContentInput } from './clear-content/clear-content.js'; @@ -98,6 +110,8 @@ import { } from './clear-content/clear-content.js'; import type { InsertInput } from './insert/insert.js'; import { executeDelete } from './delete/delete.js'; +import { executeResolveRange } from './ranges/resolve.js'; +import type { RangeResolverAdapter, ResolveRangeInput, ResolveRangeOutput } from './ranges/ranges.types.js'; import { executeInsert } from './insert/insert.js'; import type { ListsAdapter, ListsApi } from './lists/lists.js'; import type { @@ -1336,6 +1350,14 @@ export interface MutationsApi { apply(input: MutationsApplyInput): PlanReceipt; } +export interface RangesApi { + resolve(input: ResolveRangeInput): ResolveRangeOutput; +} + +export interface RangesAdapter { + resolve(input: ResolveRangeInput): ResolveRangeOutput; +} + export interface QueryAdapter { match(input: QueryMatchInput): QueryMatchOutput; } @@ -1502,6 +1524,10 @@ export interface DocumentApi { * Selector-based query with cardinality contracts for mutation targeting. */ query: QueryApi; + /** + * Deterministic range construction from explicit document anchors. + */ + ranges: RangesApi; /** * Mutation plan engine — preview and apply atomic mutation plans. */ @@ -1568,6 +1594,7 @@ export interface DocumentApiAdapters { fields?: FieldsAdapter; citations?: CitationsAdapter; authorities?: AuthoritiesAdapter; + ranges: RangesAdapter; query: QueryAdapter; mutations: MutationsAdapter; history: HistoryAdapter; @@ -2753,6 +2780,11 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return adapters.query.match(input); }, }, + ranges: { + resolve(input: ResolveRangeInput): ResolveRangeOutput { + return executeResolveRange(adapters.ranges, input); + }, + }, mutations: { preview(input: MutationsPreviewInput): MutationsPreviewOutput { return adapters.mutations.preview(input); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index b4b8f8a57f..1908ac0e40 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -182,6 +182,9 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { // --- query.* --- 'query.match': (input) => api.query.match(input), + // --- ranges.* --- + 'ranges.resolve': (input) => api.ranges.resolve(input), + // --- mutations.* --- 'mutations.preview': (input) => api.mutations.preview(input), 'mutations.apply': (input) => api.mutations.apply(input), diff --git a/packages/document-api/src/ranges/index.ts b/packages/document-api/src/ranges/index.ts new file mode 100644 index 0000000000..d0c578fb2a --- /dev/null +++ b/packages/document-api/src/ranges/index.ts @@ -0,0 +1,12 @@ +export type { + RangeAnchor, + DocumentEdgeAnchor, + PointAnchor, + RefBoundaryAnchor, + ResolveRangeInput, + ResolveRangeOutput, + RangeBlockPreview, + RangePreview, + RangeResolverAdapter, +} from './ranges.types.js'; +export { executeResolveRange } from './resolve.js'; diff --git a/packages/document-api/src/ranges/ranges.types.ts b/packages/document-api/src/ranges/ranges.types.ts new file mode 100644 index 0000000000..c6173aee37 --- /dev/null +++ b/packages/document-api/src/ranges/ranges.types.ts @@ -0,0 +1,119 @@ +/** + * Types for the `ranges.resolve` operation — deterministic range construction + * from explicit document anchors. + * + * This is a read-only composition layer that resolves two anchor endpoints + * into a contiguous `SelectionTarget` + mutation-ready `ref`. + */ + +import type { SelectionTarget, SelectionPoint } from '../types/address.js'; +import type { BlockNodeType } from '../types/base.js'; + +// --------------------------------------------------------------------------- +// Anchor types +// --------------------------------------------------------------------------- + +/** Anchor at the absolute start or end of the document body. */ +export type DocumentEdgeAnchor = { + kind: 'document'; + edge: 'start' | 'end'; +}; + +/** Anchor at an explicit selection point (text offset or node boundary). */ +export type PointAnchor = { + kind: 'point'; + point: SelectionPoint; +}; + +/** Anchor derived from an existing ref's start or end boundary. */ +export type RefBoundaryAnchor = { + kind: 'ref'; + ref: string; + boundary: 'start' | 'end'; +}; + +/** + * A range endpoint — one of three deterministic anchor forms. + * + * - `document`: absolute document boundary (start/end of body) + * - `point`: explicit `SelectionPoint` (text offset or node edge) + * - `ref`: boundary of an existing ref from `query.match` or `ranges.resolve` + */ +export type RangeAnchor = DocumentEdgeAnchor | PointAnchor | RefBoundaryAnchor; + +// --------------------------------------------------------------------------- +// Input / Output +// --------------------------------------------------------------------------- + +export interface ResolveRangeInput { + /** Start endpoint of the range. */ + start: RangeAnchor; + /** End endpoint of the range. */ + end: RangeAnchor; + /** Optional expected revision for consistency checking. */ + expectedRevision?: string; +} + +/** Per-block preview metadata within the resolved range. */ +export interface RangeBlockPreview { + nodeId: string; + nodeType: BlockNodeType; + textPreview: string; +} + +/** Preview metadata for the resolved range. */ +export interface RangePreview { + /** Concatenated text content across the range (truncated if large). */ + text: string; + /** Whether the text was truncated. */ + truncated: boolean; + /** Per-block preview entries in document order. */ + blocks: RangeBlockPreview[]; +} + +export interface ResolveRangeOutput { + /** The document revision at which the range was evaluated. */ + evaluatedRevision: string; + /** Mutation-ready handle for the resolved range. */ + handle: { + /** + * Text ref encoding the resolved range, usable as a target for mutations + * (delete, replace, format). + * + * `null` when the range covers only structural blocks with no text content + * (e.g. an image-only document). Check `coversFullTarget` and this field + * before passing to mutation operations. + */ + ref: string | null; + refStability: 'ephemeral'; + /** + * Whether the ref faithfully covers the exact same range as the target. + * + * `true` — the ref encodes the full range; using it for delete/replace/format + * produces the same result as operating directly on the target. + * + * `false` — the range spans structural block boundaries (e.g. table, image) + * that the text-based ref format cannot capture. The ref covers only the text + * content within the range, or is `null` if no text content exists. + */ + coversFullTarget: boolean; + }; + /** Transparent selection target for inspection, logging, and debugging. */ + target: SelectionTarget; + /** Preview metadata describing the content within the range. */ + preview: RangePreview; +} + +// --------------------------------------------------------------------------- +// Adapter interface +// --------------------------------------------------------------------------- + +/** + * Adapter that the super-editor implements for `ranges.resolve`. + * + * The document-api layer handles validation; the adapter performs the + * actual ProseMirror-level resolution and ref encoding. + */ +export interface RangeResolverAdapter { + resolve(input: ResolveRangeInput): ResolveRangeOutput; +} diff --git a/packages/document-api/src/ranges/resolve.test.ts b/packages/document-api/src/ranges/resolve.test.ts new file mode 100644 index 0000000000..9e4bb31973 --- /dev/null +++ b/packages/document-api/src/ranges/resolve.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, vi } from 'vitest'; +import { executeResolveRange } from './resolve.js'; +import type { RangeResolverAdapter, ResolveRangeInput, ResolveRangeOutput } from './ranges.types.js'; +import type { SelectionTarget } from '../types/address.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const STUB_TARGET: SelectionTarget = { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 0 }, + end: { kind: 'text', blockId: 'p1', offset: 5 }, +}; + +const STUB_OUTPUT: ResolveRangeOutput = { + evaluatedRevision: '1', + handle: { ref: 'text:abc', refStability: 'ephemeral', coversFullTarget: true }, + target: STUB_TARGET, + preview: { text: 'hello', truncated: false, blocks: [] }, +}; + +function createStubAdapter(output: ResolveRangeOutput = STUB_OUTPUT): RangeResolverAdapter { + return { resolve: vi.fn(() => output) }; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +describe('executeResolveRange: validation', () => { + it('rejects non-object input', () => { + const adapter = createStubAdapter(); + expect(() => executeResolveRange(adapter, null as unknown as ResolveRangeInput)).toThrow( + 'ranges.resolve input must be a non-null object', + ); + }); + + it('rejects missing start', () => { + const adapter = createStubAdapter(); + expect(() => + executeResolveRange(adapter, { end: { kind: 'document', edge: 'end' } } as unknown as ResolveRangeInput), + ).toThrow('must provide "start"'); + }); + + it('rejects missing end', () => { + const adapter = createStubAdapter(); + expect(() => + executeResolveRange(adapter, { start: { kind: 'document', edge: 'start' } } as unknown as ResolveRangeInput), + ).toThrow('must provide "end"'); + }); + + it('rejects unknown top-level fields', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + bogus: true, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow('Unknown field "bogus"'); + }); + + it('rejects non-string expectedRevision', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + expectedRevision: 42, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'expectedRevision must be a string', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Anchor validation: document +// --------------------------------------------------------------------------- + +describe('executeResolveRange: document anchor validation', () => { + it('accepts valid document anchors', () => { + const adapter = createStubAdapter(); + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + const result = executeResolveRange(adapter, input); + expect(result).toBe(STUB_OUTPUT); + expect(adapter.resolve).toHaveBeenCalledWith(input); + }); + + it('rejects invalid document edge', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'document', edge: 'middle' }, + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start.edge must be "start" or "end"', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Anchor validation: point +// --------------------------------------------------------------------------- + +describe('executeResolveRange: point anchor validation', () => { + it('accepts valid text point anchor', () => { + const adapter = createStubAdapter(); + const input: ResolveRangeInput = { + start: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 0 } }, + end: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 5 } }, + }; + executeResolveRange(adapter, input); + expect(adapter.resolve).toHaveBeenCalledWith(input); + }); + + it('accepts valid nodeEdge point anchor', () => { + const adapter = createStubAdapter(); + const input: ResolveRangeInput = { + start: { + kind: 'point', + point: { kind: 'nodeEdge', node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, edge: 'before' }, + }, + end: { kind: 'document', edge: 'end' }, + }; + executeResolveRange(adapter, input); + expect(adapter.resolve).toHaveBeenCalledWith(input); + }); + + it('rejects invalid point', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'point', point: { kind: 'text', blockId: '', offset: 0 } }, + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start.point must be a valid SelectionPoint', + ); + }); + + it('rejects negative offset in point', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: -1 } }, + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start.point must be a valid SelectionPoint', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Anchor validation: ref +// --------------------------------------------------------------------------- + +describe('executeResolveRange: ref anchor validation', () => { + it('accepts valid ref anchor', () => { + const adapter = createStubAdapter(); + const input: ResolveRangeInput = { + start: { kind: 'ref', ref: 'text:abc123', boundary: 'start' }, + end: { kind: 'ref', ref: 'text:def456', boundary: 'end' }, + }; + executeResolveRange(adapter, input); + expect(adapter.resolve).toHaveBeenCalledWith(input); + }); + + it('rejects empty ref string', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'ref', ref: '', boundary: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start.ref must be a non-empty string', + ); + }); + + it('rejects invalid boundary', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'ref', ref: 'text:abc', boundary: 'middle' }, + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start.boundary must be "start" or "end"', + ); + }); + + it('rejects missing boundary', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'ref', ref: 'text:abc' }, + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start.boundary must be "start" or "end"', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Anchor validation: kind +// --------------------------------------------------------------------------- + +describe('executeResolveRange: anchor kind validation', () => { + it('rejects unknown anchor kind', () => { + const adapter = createStubAdapter(); + const input = { + start: { kind: 'fuzzy', pattern: 'Exhibit B' }, + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start.kind must be "document", "point", or "ref"', + ); + }); + + it('rejects non-object anchor', () => { + const adapter = createStubAdapter(); + const input = { + start: 'document:start', + end: { kind: 'document', edge: 'end' }, + }; + expect(() => executeResolveRange(adapter, input as unknown as ResolveRangeInput)).toThrow( + 'start must be a non-null object', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Delegation +// --------------------------------------------------------------------------- + +describe('executeResolveRange: delegation to adapter', () => { + it('passes validated input through to adapter', () => { + const adapter = createStubAdapter(); + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + expectedRevision: '5', + }; + + const result = executeResolveRange(adapter, input); + + expect(adapter.resolve).toHaveBeenCalledOnce(); + expect(adapter.resolve).toHaveBeenCalledWith(input); + expect(result).toBe(STUB_OUTPUT); + }); + + it('returns adapter output directly', () => { + const customOutput: ResolveRangeOutput = { + evaluatedRevision: '42', + handle: { ref: 'text:custom', refStability: 'ephemeral', coversFullTarget: true }, + target: STUB_TARGET, + preview: { + text: 'custom text', + truncated: true, + blocks: [{ nodeId: 'p1', nodeType: 'paragraph', textPreview: 'custom' }], + }, + }; + const adapter = createStubAdapter(customOutput); + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + const result = executeResolveRange(adapter, input); + expect(result).toBe(customOutput); + }); +}); diff --git a/packages/document-api/src/ranges/resolve.ts b/packages/document-api/src/ranges/resolve.ts new file mode 100644 index 0000000000..dde2c1ba28 --- /dev/null +++ b/packages/document-api/src/ranges/resolve.ts @@ -0,0 +1,119 @@ +/** + * `ranges.resolve` operation — deterministic range construction from + * explicit document anchors. + * + * Validates input shape, then delegates to the RangeResolverAdapter + * for ProseMirror-level resolution, ref encoding, and preview generation. + */ + +import type { ResolveRangeInput, ResolveRangeOutput, RangeResolverAdapter, RangeAnchor } from './ranges.types.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord, assertNoUnknownFields } from '../validation-primitives.js'; +import { isSelectionPoint } from '../validation/selection-target-validator.js'; + +// --------------------------------------------------------------------------- +// Anchor validation +// --------------------------------------------------------------------------- + +const VALID_DOCUMENT_EDGES: ReadonlySet = new Set(['start', 'end']); +const VALID_REF_BOUNDARIES: ReadonlySet = new Set(['start', 'end']); +const VALID_ANCHOR_KINDS: ReadonlySet = new Set(['document', 'point', 'ref']); + +function validateAnchor(value: unknown, fieldName: string): asserts value is RangeAnchor { + if (!isRecord(value)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${fieldName} must be a non-null object.`, { + field: fieldName, + }); + } + + if (typeof value.kind !== 'string' || !VALID_ANCHOR_KINDS.has(value.kind)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${fieldName}.kind must be "document", "point", or "ref", got ${JSON.stringify(value.kind)}.`, + { field: `${fieldName}.kind`, value: value.kind }, + ); + } + + switch (value.kind) { + case 'document': + if (typeof value.edge !== 'string' || !VALID_DOCUMENT_EDGES.has(value.edge)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${fieldName}.edge must be "start" or "end", got ${JSON.stringify(value.edge)}.`, + { field: `${fieldName}.edge`, value: value.edge }, + ); + } + break; + + case 'point': + if (!isSelectionPoint(value.point)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${fieldName}.point must be a valid SelectionPoint.`, { + field: `${fieldName}.point`, + value: value.point, + }); + } + break; + + case 'ref': + if (typeof value.ref !== 'string' || value.ref === '') { + throw new DocumentApiValidationError('INVALID_INPUT', `${fieldName}.ref must be a non-empty string.`, { + field: `${fieldName}.ref`, + value: value.ref, + }); + } + if (typeof value.boundary !== 'string' || !VALID_REF_BOUNDARIES.has(value.boundary)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${fieldName}.boundary must be "start" or "end", got ${JSON.stringify(value.boundary)}.`, + { field: `${fieldName}.boundary`, value: value.boundary }, + ); + } + break; + } +} + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +const RESOLVE_RANGE_ALLOWED_KEYS = new Set(['start', 'end', 'expectedRevision']); + +function validateResolveRangeInput(input: unknown): asserts input is ResolveRangeInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'ranges.resolve input must be a non-null object.'); + } + + assertNoUnknownFields(input, RESOLVE_RANGE_ALLOWED_KEYS, 'ranges.resolve'); + + if (input.start === undefined) { + throw new DocumentApiValidationError('INVALID_INPUT', 'ranges.resolve input must provide "start".', { + field: 'start', + }); + } + + if (input.end === undefined) { + throw new DocumentApiValidationError('INVALID_INPUT', 'ranges.resolve input must provide "end".', { + field: 'end', + }); + } + + validateAnchor(input.start, 'start'); + validateAnchor(input.end, 'end'); + + if (input.expectedRevision !== undefined && typeof input.expectedRevision !== 'string') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `expectedRevision must be a string, got ${typeof input.expectedRevision}.`, + { field: 'expectedRevision', value: input.expectedRevision }, + ); + } +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +export function executeResolveRange(adapter: RangeResolverAdapter, input: ResolveRangeInput): ResolveRangeOutput { + validateResolveRangeInput(input); + return adapter.resolve(input); +} diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts index 32b33179bd..bdeac0fff7 100644 --- a/packages/document-api/src/types/address.ts +++ b/packages/document-api/src/types/address.ts @@ -51,10 +51,9 @@ export type TextTarget = { * * Excludes: * - `tableRow`, `tableCell` — row/column semantics out of scope - * - `image` — block-image deletion not yet proven end-to-end * - `listItem` — derived from paragraph attrs, no distinct PM wrapper node */ -export type SelectionEdgeNodeType = Exclude; +export type SelectionEdgeNodeType = Exclude; export const SELECTION_EDGE_NODE_TYPES = [ 'paragraph', @@ -62,6 +61,7 @@ export const SELECTION_EDGE_NODE_TYPES = [ 'table', 'tableOfContents', 'sdt', + 'image', ] as const satisfies readonly SelectionEdgeNodeType[]; /** Block node address valid as a `nodeEdge` selection anchor. */ 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 4feb54bb1e..374929b817 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -85,6 +85,7 @@ import { import { executePlan } from './plan-engine/executor.js'; import { previewPlan } from './plan-engine/preview.js'; import { queryMatchAdapter } from './plan-engine/query-match-adapter.js'; +import { resolveRange } from './helpers/range-resolver.js'; import { initRevision, trackRevisions } from './plan-engine/revision-tracker.js'; import { registerBuiltInExecutors } from './plan-engine/register-executors.js'; import { registerPartDescriptor } from '../core/parts/registry/part-registry.js'; @@ -666,6 +667,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters remove: (input, options) => authorityEntriesRemoveWrapper(editor, input, options), }, }, + ranges: { + resolve: (input) => resolveRange(editor, input), + }, query: { match: (input) => queryMatchAdapter(editor, input), }, diff --git a/packages/super-editor/src/document-api-adapters/helpers/range-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/range-resolver.test.ts new file mode 100644 index 0000000000..94f0c4f510 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/range-resolver.test.ts @@ -0,0 +1,766 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { ResolveRangeInput } from '@superdoc/document-api'; +import type { BlockCandidate, BlockIndex } from './node-address-resolver.js'; +import { resolveRange } from './range-resolver.js'; +import { PlanError } from '../plan-engine/errors.js'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + getBlockIndex: vi.fn(), + resolveSelectionPointPosition: vi.fn(), + encodeV3Ref: vi.fn(() => 'text:mock-encoded'), + getRevision: vi.fn(() => '0'), + checkRevision: vi.fn(), +})); + +vi.mock('./index-cache.js', () => ({ + getBlockIndex: mocks.getBlockIndex, +})); + +vi.mock('./selection-target-resolver.js', () => ({ + resolveSelectionPointPosition: mocks.resolveSelectionPointPosition, +})); + +vi.mock('../plan-engine/query-match-adapter.js', () => ({ + encodeV3Ref: mocks.encodeV3Ref, +})); + +vi.mock('../plan-engine/revision-tracker.js', () => ({ + getRevision: mocks.getRevision, + checkRevision: mocks.checkRevision, +})); + +// Provide isTextBlockCandidate — the only value import from this module. +vi.mock('./node-address-resolver.js', () => ({ + isTextBlockCandidate: (candidate: { node: { inlineContent?: boolean; isTextblock?: boolean } }) => + Boolean(candidate.node?.inlineContent || candidate.node?.isTextblock), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Maps absolute PM positions to single characters for a lightweight textBetween stub. + * `textBetween(from, to)` concatenates mapped chars for positions `[from, to)`. + */ +function makeTextBetween(charMap: Map) { + return (from: number, to: number): string => { + let result = ''; + for (let pos = from; pos < to; pos++) { + result += charMap.get(pos) ?? ''; + } + return result; + }; +} + +function makeEditor(docContentSize: number, charMap: Map): Editor { + return { + state: { + doc: { + content: { size: docContentSize }, + textBetween: makeTextBetween(charMap), + }, + }, + } as unknown as Editor; +} + +/** + * Creates a mock block candidate. + * + * @param inlineContent - Set to `true` for text blocks (paragraph, heading), + * `false` for structural blocks (table, tableRow). Defaults to `true`. + */ +function makeCandidate( + nodeId: string, + nodeType: string, + pos: number, + end: number, + options: { inlineContent?: boolean } = {}, +): BlockCandidate { + const inlineContent = options.inlineContent ?? true; + return { + node: { inlineContent, isTextblock: inlineContent } as any, + pos, + end, + nodeType, + nodeId, + } as BlockCandidate; +} + +function makeIndex(candidates: BlockCandidate[]): BlockIndex { + return { + candidates, + byId: new Map(candidates.map((c) => [`${c.nodeType}:${c.nodeId}`, c])), + ambiguous: new Set(), + }; +} + +/** Encodes a test ref matching the V3 text ref format consumed by resolveRefAnchor. */ +function encodeTestRef(rev: string, segments: Array<{ blockId: string; start: number; end: number }>): string { + return `text:${btoa(JSON.stringify({ v: 3, rev, segments }))}`; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** + * Single paragraph "ABCDE" (5 chars). + * + * paragraph: pos=0, content=[1..5], end=7 + * doc.content.size = 7 + * Document start → pos=0 (nodeEdge before), end → pos=7 (nodeEdge after) + */ +function singleParagraph() { + const chars = new Map([ + [1, 'A'], + [2, 'B'], + [3, 'C'], + [4, 'D'], + [5, 'E'], + ]); + return { + editor: makeEditor(7, chars), + index: makeIndex([makeCandidate('p1', 'paragraph', 0, 7)]), + }; +} + +/** + * Two paragraphs "ABC" + "DEF" (3 chars each). + * + * p1: pos=0, content=[1..3], end=5 + * p2: pos=5, content=[6..8], end=10 + * doc.content.size = 10 + * Document start → pos=0, end → pos=10 + */ +function twoParagraphs() { + const chars = new Map([ + [1, 'A'], + [2, 'B'], + [3, 'C'], + [6, 'D'], + [7, 'E'], + [8, 'F'], + ]); + return { + editor: makeEditor(10, chars), + index: makeIndex([makeCandidate('p1', 'paragraph', 0, 5), makeCandidate('p2', 'paragraph', 5, 10)]), + }; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getRevision.mockReturnValue('0'); + mocks.encodeV3Ref.mockReturnValue('text:mock-encoded'); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('resolveRange', () => { + // ----------------------------------------------------------------------- + // Document-edge anchors + // ----------------------------------------------------------------------- + + describe('document-edge anchors', () => { + const WHOLE_DOC_INPUT: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + it('resolves whole-document range to nodeEdge boundaries for a single paragraph', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const result = resolveRange(editor, WHOLE_DOC_INPUT); + + // Document start = first.pos = 0, end = max(candidates.end) = 7 + expect(result.target).toEqual({ + kind: 'selection', + start: { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + edge: 'before', + }, + end: { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + edge: 'after', + }, + }); + expect(result.preview.text).toBe('ABCDE'); + expect(result.preview.truncated).toBe(false); + expect(result.preview.blocks).toEqual([{ nodeId: 'p1', nodeType: 'paragraph', textPreview: 'ABCDE' }]); + }); + + it('resolves whole-document range across multiple paragraphs', () => { + const { editor, index } = twoParagraphs(); + mocks.getBlockIndex.mockReturnValue(index); + + const result = resolveRange(editor, WHOLE_DOC_INPUT); + + // Document start = first.pos = 0, end = max(candidates.end) = 10 + expect(result.target).toEqual({ + kind: 'selection', + start: { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + edge: 'before', + }, + end: { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + edge: 'after', + }, + }); + expect(result.preview.text).toBe('ABC\nDEF'); + expect(result.preview.blocks).toEqual([ + { nodeId: 'p1', nodeType: 'paragraph', textPreview: 'ABC' }, + { nodeId: 'p2', nodeType: 'paragraph', textPreview: 'DEF' }, + ]); + }); + + it('resolves document edges to nodeEdge points when boundary block is an image', () => { + // Image block (leaf, non-text, now in SELECTION_EDGE_NODE_TYPES). + const chars = new Map(); + const editor = makeEditor(3, chars); + const index = makeIndex([makeCandidate('img1', 'image', 0, 3, { inlineContent: false })]); + mocks.getBlockIndex.mockReturnValue(index); + + const result = resolveRange(editor, WHOLE_DOC_INPUT); + + expect(result.target.start).toEqual({ + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'image', nodeId: 'img1' }, + edge: 'before', + }); + expect(result.target.end).toEqual({ + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'image', nodeId: 'img1' }, + edge: 'after', + }); + }); + + it('resolves document edges to nodeEdge points when boundary block is non-text', () => { + // Table (structural) containing a paragraph (text block). + // table: pos=0, end=20 (inlineContent: false) + // paragraph: pos=3, end=12 (inlineContent: true) + const chars = new Map([ + [4, 'A'], + [5, 'B'], + [6, 'C'], + ]); + const editor = makeEditor(20, chars); + const index = makeIndex([ + makeCandidate('t1', 'table', 0, 20, { inlineContent: false }), + makeCandidate('p1', 'paragraph', 3, 12), + ]); + mocks.getBlockIndex.mockReturnValue(index); + + const result = resolveRange(editor, WHOLE_DOC_INPUT); + + // Document start = table.pos = 0, end = max(table.end, para.end) = 20 + expect(result.target.start).toEqual({ + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'table', nodeId: 't1' }, + edge: 'before', + }); + expect(result.target.end).toEqual({ + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'table', nodeId: 't1' }, + edge: 'after', + }); + }); + }); + + // ----------------------------------------------------------------------- + // Point anchors + // ----------------------------------------------------------------------- + + describe('point anchors', () => { + it('delegates point resolution to resolveSelectionPointPosition', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + mocks.resolveSelectionPointPosition + .mockReturnValueOnce(2) // start → abs position 2 + .mockReturnValueOnce(4); // end → abs position 4 + + const startPoint = { kind: 'text' as const, blockId: 'p1', offset: 1 }; + const endPoint = { kind: 'text' as const, blockId: 'p1', offset: 3 }; + const input: ResolveRangeInput = { + start: { kind: 'point', point: startPoint }, + end: { kind: 'point', point: endPoint }, + }; + + const result = resolveRange(editor, input); + + expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledTimes(2); + expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledWith(editor, startPoint); + expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledWith(editor, endPoint); + + // absFrom=2, absTo=4 → both inside p1 content [1..6] + expect(result.target.start).toEqual({ kind: 'text', blockId: 'p1', offset: 1 }); + expect(result.target.end).toEqual({ kind: 'text', blockId: 'p1', offset: 3 }); + expect(result.preview.text).toBe('BC'); + }); + }); + + // ----------------------------------------------------------------------- + // Ref anchors + // ----------------------------------------------------------------------- + + describe('ref anchors', () => { + it('resolves valid text ref boundaries', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const ref = encodeTestRef('0', [{ blockId: 'p1', start: 1, end: 4 }]); + mocks.resolveSelectionPointPosition + .mockReturnValueOnce(2) // start boundary → pos 2 + .mockReturnValueOnce(5); // end boundary → pos 5 + + const input: ResolveRangeInput = { + start: { kind: 'ref', ref, boundary: 'start' }, + end: { kind: 'ref', ref, boundary: 'end' }, + }; + + const result = resolveRange(editor, input); + + expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledTimes(2); + // Start boundary extracts first segment's start offset + expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledWith(editor, { + kind: 'text', + blockId: 'p1', + offset: 1, + }); + // End boundary extracts last segment's end offset + expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledWith(editor, { + kind: 'text', + blockId: 'p1', + offset: 4, + }); + expect(result.evaluatedRevision).toBe('0'); + expect(result.target.kind).toBe('selection'); + }); + + it('rejects non-text ref prefix', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'ref', ref: 'node:abc', boundary: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + expect(() => resolveRange(editor, input)).toThrow('Only text refs'); + }); + + it('rejects malformed base64 encoding', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'ref', ref: 'text:!!!not-base64!!!', boundary: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + expect(() => resolveRange(editor, input)).toThrow('Invalid text ref encoding'); + }); + + it('rejects ref with no segments', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const ref = encodeTestRef('0', []); + const input: ResolveRangeInput = { + start: { kind: 'ref', ref, boundary: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + expect(() => resolveRange(editor, input)).toThrow('no segments'); + }); + + it('rejects stale ref with REVISION_MISMATCH error code', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const ref = encodeTestRef('5', [{ blockId: 'p1', start: 0, end: 3 }]); + const input: ResolveRangeInput = { + start: { kind: 'ref', ref, boundary: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + expect(() => resolveRange(editor, input)).toThrow(PlanError); + expect(() => resolveRange(editor, input)).toThrow('REVISION_MISMATCH'); + }); + }); + + // ----------------------------------------------------------------------- + // Mixed anchor types + // ----------------------------------------------------------------------- + + describe('mixed anchor types', () => { + it('combines document start with point end', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + mocks.resolveSelectionPointPosition.mockReturnValueOnce(3); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 2 } }, + }; + + const result = resolveRange(editor, input); + + // Document start = first.pos = 0 → nodeEdge before, point end = 3 → text offset 2 + expect(result.target.start).toEqual({ + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + edge: 'before', + }); + expect(result.target.end).toEqual({ kind: 'text', blockId: 'p1', offset: 2 }); + expect(result.preview.text).toBe('AB'); + }); + }); + + // ----------------------------------------------------------------------- + // Direction normalization + // ----------------------------------------------------------------------- + + describe('direction normalization', () => { + it('normalizes when start resolves after end', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + mocks.resolveSelectionPointPosition + .mockReturnValueOnce(5) // "start" resolves to higher position + .mockReturnValueOnce(2); // "end" resolves to lower position + + const input: ResolveRangeInput = { + start: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 4 } }, + end: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 1 } }, + }; + + const result = resolveRange(editor, input); + + // After normalization: absFrom=2, absTo=5 + expect(result.target.start).toEqual({ kind: 'text', blockId: 'p1', offset: 1 }); + expect(result.target.end).toEqual({ kind: 'text', blockId: 'p1', offset: 4 }); + }); + }); + + // ----------------------------------------------------------------------- + // Revision handling + // ----------------------------------------------------------------------- + + describe('revision handling', () => { + it('includes evaluatedRevision from getRevision', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + mocks.getRevision.mockReturnValue('42'); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + const result = resolveRange(editor, input); + + expect(result.evaluatedRevision).toBe('42'); + }); + + it('calls checkRevision when expectedRevision is provided', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + expectedRevision: '0', + }; + + resolveRange(editor, input); + + expect(mocks.checkRevision).toHaveBeenCalledOnce(); + expect(mocks.checkRevision).toHaveBeenCalledWith(editor, '0'); + }); + + it('skips checkRevision when expectedRevision is omitted', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + resolveRange(editor, input); + + expect(mocks.checkRevision).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Output structure + // ----------------------------------------------------------------------- + + describe('output structure', () => { + it('returns handle with ephemeral refStability', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + const result = resolveRange(editor, input); + + expect(result.handle.refStability).toBe('ephemeral'); + expect(result.handle.ref).toBe('text:mock-encoded'); + }); + + it('sets coversFullTarget=true when both target endpoints are text points', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + mocks.resolveSelectionPointPosition.mockReturnValueOnce(2).mockReturnValueOnce(4); + + const input: ResolveRangeInput = { + start: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 1 } }, + end: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 3 } }, + }; + + const result = resolveRange(editor, input); + + expect(result.handle.coversFullTarget).toBe(true); + }); + + it('sets coversFullTarget=true when selection is inside a text block nested in a structural ancestor', () => { + // Table (structural ancestor) wrapping a paragraph (text block). + // Selection entirely within the paragraph — the table is a benign ancestor. + const chars = new Map([ + [4, 'A'], + [5, 'B'], + [6, 'C'], + ]); + const editor = makeEditor(20, chars); + const index = makeIndex([ + makeCandidate('t1', 'table', 0, 20, { inlineContent: false }), + makeCandidate('p1', 'paragraph', 3, 12), + ]); + mocks.getBlockIndex.mockReturnValue(index); + + // Points inside the paragraph content. + mocks.resolveSelectionPointPosition + .mockReturnValueOnce(4) // start inside p1 + .mockReturnValueOnce(6); // end inside p1 + + const input: ResolveRangeInput = { + start: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 0 } }, + end: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 2 } }, + }; + + const result = resolveRange(editor, input); + + expect(result.target.start.kind).toBe('text'); + expect(result.target.end.kind).toBe('text'); + expect(result.handle.coversFullTarget).toBe(true); + }); + + it('sets coversFullTarget=false when range crosses structural content between text endpoints', () => { + // p1 (text) → image (structural) → p2 (text) + // Both endpoints are text points, but the image in between makes the ref lossy. + const chars = new Map([ + [1, 'A'], + [2, 'B'], + [9, 'C'], + [10, 'D'], + ]); + const editor = makeEditor(12, chars); + const index = makeIndex([ + makeCandidate('p1', 'paragraph', 0, 4), + makeCandidate('img1', 'image', 4, 7, { inlineContent: false }), + makeCandidate('p2', 'paragraph', 7, 12), + ]); + mocks.getBlockIndex.mockReturnValue(index); + + // Point anchors inside p1 and p2 — both resolve to text content positions. + mocks.resolveSelectionPointPosition + .mockReturnValueOnce(1) // start inside p1 + .mockReturnValueOnce(10); // end inside p2 + + const input: ResolveRangeInput = { + start: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 0 } }, + end: { kind: 'point', point: { kind: 'text', blockId: 'p2', offset: 1 } }, + }; + + const result = resolveRange(editor, input); + + // Both endpoints are text kind... + expect(result.target.start.kind).toBe('text'); + expect(result.target.end.kind).toBe('text'); + // ...but the image in between means the ref is lossy + expect(result.handle.coversFullTarget).toBe(false); + }); + + it('sets coversFullTarget=false when target endpoints include nodeEdge points', () => { + const { editor, index } = singleParagraph(); + mocks.getBlockIndex.mockReturnValue(index); + + // Document edges resolve to outer block boundaries → nodeEdge points + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + const result = resolveRange(editor, input); + + expect(result.handle.coversFullTarget).toBe(false); + }); + + it('passes correct segments to encodeV3Ref for multi-block range', () => { + const { editor, index } = twoParagraphs(); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + resolveRange(editor, input); + + expect(mocks.encodeV3Ref).toHaveBeenCalledOnce(); + const payload = mocks.encodeV3Ref.mock.calls[0]![0]; + expect(payload).toMatchObject({ + v: 3, + rev: '0', + matchId: 'range:0-10', + scope: 'match', + segments: [ + { blockId: 'p1', start: 0, end: 3 }, + { blockId: 'p2', start: 0, end: 3 }, + ], + }); + }); + + it('maps block boundary positions to nodeEdge selection points', () => { + // Single paragraph: pos=0, content=[1..1], end=3 + const chars = new Map([[1, 'X']]); + const editor = makeEditor(3, chars); + const index = makeIndex([makeCandidate('p1', 'paragraph', 0, 3)]); + mocks.getBlockIndex.mockReturnValue(index); + + // Resolve to block boundary positions: pos=0 (before) and end=3 (after) + mocks.resolveSelectionPointPosition.mockReturnValueOnce(0).mockReturnValueOnce(3); + + const input: ResolveRangeInput = { + start: { + kind: 'point', + point: { kind: 'nodeEdge', node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, edge: 'before' }, + }, + end: { + kind: 'point', + point: { kind: 'nodeEdge', node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, edge: 'after' }, + }, + }; + + const result = resolveRange(editor, input); + + expect(result.target.start).toEqual({ + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + edge: 'before', + }); + expect(result.target.end).toEqual({ + kind: 'nodeEdge', + node: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + edge: 'after', + }); + }); + + it('encodes ref segments only for text-block candidates', () => { + // Table (structural) wrapping a paragraph (text). + const chars = new Map([ + [4, 'A'], + [5, 'B'], + [6, 'C'], + ]); + const editor = makeEditor(20, chars); + const index = makeIndex([ + makeCandidate('t1', 'table', 0, 20, { inlineContent: false }), + makeCandidate('p1', 'paragraph', 3, 12), + ]); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + resolveRange(editor, input); + + const payload = mocks.encodeV3Ref.mock.calls[0]![0]; + // Table candidate is skipped (not a text block). + // Only the nested paragraph produces a segment. + expect(payload.segments).toEqual([{ blockId: 'p1', start: 0, end: 3 }]); + }); + + it('returns null ref for textless structural ranges (image-only document)', () => { + // Image block only — no text blocks in the document at all. + const chars = new Map(); + const editor = makeEditor(3, chars); + const index = makeIndex([makeCandidate('img1', 'image', 0, 3, { inlineContent: false })]); + mocks.getBlockIndex.mockReturnValue(index); + + const input: ResolveRangeInput = { + start: { kind: 'document', edge: 'start' }, + end: { kind: 'document', edge: 'end' }, + }; + + const result = resolveRange(editor, input); + + // No text blocks → ref cannot be encoded → null + expect(result.handle.ref).toBeNull(); + expect(result.handle.coversFullTarget).toBe(false); + // encodeV3Ref should NOT have been called + expect(mocks.encodeV3Ref).not.toHaveBeenCalled(); + }); + + it('produces a fallback segment when range spans only structural boundaries', () => { + // Table with a nested paragraph, but range is collapsed at table boundary. + const chars = new Map([[4, 'A']]); + const editor = makeEditor(20, chars); + const index = makeIndex([ + makeCandidate('t1', 'table', 0, 20, { inlineContent: false }), + makeCandidate('p1', 'paragraph', 3, 6), + ]); + mocks.getBlockIndex.mockReturnValue(index); + + // Point anchors that resolve to the table's outer boundaries (collapsed at pos 0). + mocks.resolveSelectionPointPosition.mockReturnValueOnce(0).mockReturnValueOnce(0); + + const input: ResolveRangeInput = { + start: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 0 } }, + end: { kind: 'point', point: { kind: 'text', blockId: 'p1', offset: 0 } }, + }; + + resolveRange(editor, input); + + const payload = mocks.encodeV3Ref.mock.calls[0]![0]; + // No candidates overlap the collapsed range [0, 0], but the fallback + // finds the nearest text block (p1) and creates a zero-width segment. + expect(payload.segments).toHaveLength(1); + expect(payload.segments[0].blockId).toBe('p1'); + expect(payload.segments[0].start).toBe(payload.segments[0].end); + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts new file mode 100644 index 0000000000..7bdc4e01ba --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts @@ -0,0 +1,462 @@ +/** + * Range resolver adapter — resolves two explicit anchors into a contiguous + * document range with a transparent SelectionTarget and mutation-ready ref. + * + * Composes existing primitives: + * - SelectionPoint resolution (selection-target-resolver.ts) + * - V3 ref encoding (query-match-adapter.ts) + * - Revision tracking (revision-tracker.ts) + * - Block index (index-cache.ts) + */ + +import type { + ResolveRangeInput, + ResolveRangeOutput, + RangeAnchor, + RangeBlockPreview, + SelectionTarget, + SelectionPoint, + SelectionEdgeNodeType, +} from '@superdoc/document-api'; +import { SELECTION_EDGE_NODE_TYPES } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { getBlockIndex } from './index-cache.js'; +import { isTextBlockCandidate, type BlockCandidate, type BlockIndex } from './node-address-resolver.js'; +import { resolveSelectionPointPosition } from './selection-target-resolver.js'; +import { encodeV3Ref } from '../plan-engine/query-match-adapter.js'; +import { getRevision, checkRevision } from '../plan-engine/revision-tracker.js'; +import { PlanError } from '../plan-engine/errors.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PREVIEW_TEXT_MAX_LENGTH = 2000; +const BLOCK_PREVIEW_MAX_LENGTH = 200; + +const EDGE_NODE_TYPES: ReadonlySet = new Set(SELECTION_EDGE_NODE_TYPES); + +// --------------------------------------------------------------------------- +// Document-edge resolution +// --------------------------------------------------------------------------- + +/** + * Resolves "document start" to the first block's outer boundary position. + * + * Using the block's `pos` (instead of a hardcoded interior position) ensures + * non-text blocks like tables produce valid nodeEdge selection points rather + * than invalid text points. + */ +function resolveDocumentStart(index: BlockIndex): number { + const first = index.candidates[0]; + return first ? first.pos : 1; +} + +/** + * Resolves "document end" to the outermost last block's outer boundary. + * + * Uses the maximum `end` across all candidates (not just the last in the list) + * because nested blocks (e.g. paragraphs inside a table) may appear after + * their container in the flat candidate list yet end before it. + */ +function resolveDocumentEnd(editor: Editor, index: BlockIndex): number { + let maxEnd = 0; + for (const c of index.candidates) { + if (c.end > maxEnd) maxEnd = c.end; + } + return maxEnd > 0 ? maxEnd : editor.state.doc.content.size - 1; +} + +// --------------------------------------------------------------------------- +// Ref anchor resolution +// --------------------------------------------------------------------------- + +/** + * Decodes a text ref and extracts the start or end boundary as an absolute position. + * + * Only accepts `text:` prefixed refs (V3 text refs from query.match or ranges.resolve). + */ +function resolveRefAnchor(editor: Editor, ref: string, boundary: 'start' | 'end', revision: string): number { + if (!ref.startsWith('text:')) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Only text refs (from query.match or ranges.resolve) are valid range anchors. Got prefix: "${ref.split(':')[0]}".`, + { ref, boundary }, + ); + } + + const encoded = ref.slice('text:'.length); + let payload: unknown; + try { + payload = JSON.parse(atob(encoded)); + } catch { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Invalid text ref encoding.', { ref, boundary }); + } + + const data = payload as { + v?: number; + rev?: string; + segments?: Array<{ blockId: string; start: number; end: number }>; + }; + + if (!data.segments?.length) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Ref contains no segments.', { ref, boundary }); + } + + if (data.rev !== revision) { + throw new PlanError( + 'REVISION_MISMATCH', + `REVISION_MISMATCH — ref was created at revision ${data.rev} but document is at revision ${revision}. Re-run the discovery operation to obtain a fresh ref.`, + undefined, + { + ref, + boundary, + refRevision: data.rev, + currentRevision: revision, + refStability: 'ephemeral', + remediation: 'Re-run ranges.resolve or query.match to obtain a fresh ref valid for the current revision.', + }, + ); + } + + const seg = boundary === 'start' ? data.segments[0] : data.segments[data.segments.length - 1]; + const offset = boundary === 'start' ? seg.start : seg.end; + const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset }; + + return resolveSelectionPointPosition(editor, point); +} + +// --------------------------------------------------------------------------- +// Anchor dispatch +// --------------------------------------------------------------------------- + +function resolveAnchor(editor: Editor, anchor: RangeAnchor, revision: string, index: BlockIndex): number { + switch (anchor.kind) { + case 'document': + return anchor.edge === 'start' ? resolveDocumentStart(index) : resolveDocumentEnd(editor, index); + case 'point': + return resolveSelectionPointPosition(editor, anchor.point); + case 'ref': + return resolveRefAnchor(editor, anchor.ref, anchor.boundary, revision); + } +} + +// --------------------------------------------------------------------------- +// Absolute position → SelectionPoint mapping +// --------------------------------------------------------------------------- + +/** + * Returns true when the block's node type is valid for nodeEdge selection anchors. + */ +function isEdgeNodeType(nodeType: string): nodeType is SelectionEdgeNodeType { + return EDGE_NODE_TYPES.has(nodeType); +} + +/** + * Computes the text-model character offset from block content start to an + * absolute PM position. + */ +function computeTextOffset(editor: Editor, blockContentStart: number, absPos: number): number { + if (absPos <= blockContentStart) return 0; + return editor.state.doc.textBetween(blockContentStart, absPos, '', '\ufffc').length; +} + +/** + * Converts an absolute PM position to a SelectionPoint by finding the + * enclosing block and computing the character offset or node-edge boundary. + */ +function absPositionToSelectionPoint(editor: Editor, index: BlockIndex, absPos: number): SelectionPoint { + for (const candidate of index.candidates) { + const blockContentStart = candidate.pos + 1; + const blockContentEnd = candidate.end - 1; + + // Position at this block's opening boundary → nodeEdge before (if valid type) + if (absPos === candidate.pos && isEdgeNodeType(candidate.nodeType)) { + return { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: candidate.nodeType, nodeId: candidate.nodeId }, + edge: 'before', + }; + } + + // Position at this block's closing boundary → nodeEdge after (if valid type) + if (absPos === candidate.end && isEdgeNodeType(candidate.nodeType)) { + return { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: candidate.nodeType, nodeId: candidate.nodeId }, + edge: 'after', + }; + } + + // Position inside this block's content → text point (text blocks only). + // Structural containers (table, tableRow) are skipped so that nested + // text-block candidates get a chance to match. + if (absPos >= blockContentStart && absPos <= blockContentEnd && isTextBlockCandidate(candidate)) { + return { + kind: 'text', + blockId: candidate.nodeId, + offset: computeTextOffset(editor, blockContentStart, absPos), + }; + } + } + + // Edge case: position falls between blocks (in PM gap positions). + // Map to the nearest block boundary. + return resolveGapPosition(index, absPos); +} + +/** + * Handles positions that fall in PM structural gaps (between block nodes). + * Maps to the nearest valid block boundary. + */ +function resolveGapPosition(index: BlockIndex, absPos: number): SelectionPoint { + const first = index.candidates[0]; + const last = index.candidates[index.candidates.length - 1]; + + if (first && absPos <= first.pos && isEdgeNodeType(first.nodeType)) { + return { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: first.nodeType, nodeId: first.nodeId }, + edge: 'before', + }; + } + + if (last && absPos >= last.end && isEdgeNodeType(last.nodeType)) { + return { + kind: 'nodeEdge', + node: { kind: 'block', nodeType: last.nodeType, nodeId: last.nodeId }, + edge: 'after', + }; + } + + // Last resort: use text offset 0 of the nearest block + const fallback = first ?? last; + if (fallback) { + return { kind: 'text', blockId: fallback.nodeId, offset: 0 }; + } + + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Could not map position ${absPos} to a SelectionPoint — document appears empty.`, + { absPos }, + ); +} + +// --------------------------------------------------------------------------- +// SelectionTarget construction +// --------------------------------------------------------------------------- + +function buildSelectionTarget(editor: Editor, index: BlockIndex, absFrom: number, absTo: number): SelectionTarget { + return { + kind: 'selection', + start: absPositionToSelectionPoint(editor, index, absFrom), + end: absPositionToSelectionPoint(editor, index, absTo), + }; +} + +// --------------------------------------------------------------------------- +// Preview generation +// --------------------------------------------------------------------------- + +/** + * Iterates blocks overlapping [absFrom, absTo) and collects: + * - per-block preview entries + * - concatenated text preview (truncated if needed) + */ +function buildPreview( + editor: Editor, + index: BlockIndex, + absFrom: number, + absTo: number, +): { text: string; truncated: boolean; blocks: RangeBlockPreview[] } { + const blocks: RangeBlockPreview[] = []; + let fullText = ''; + + for (const candidate of index.candidates) { + if (candidate.end <= absFrom || candidate.pos >= absTo) continue; + + const blockContentStart = candidate.pos + 1; + const blockContentEnd = candidate.end - 1; + const rangeStart = Math.max(blockContentStart, absFrom); + const rangeEnd = Math.min(blockContentEnd, absTo); + if (rangeStart > rangeEnd) continue; + + const blockText = editor.state.doc.textBetween(rangeStart, rangeEnd, '', '\ufffc'); + + blocks.push({ + nodeId: candidate.nodeId, + nodeType: candidate.nodeType, + textPreview: + blockText.length > BLOCK_PREVIEW_MAX_LENGTH ? blockText.slice(0, BLOCK_PREVIEW_MAX_LENGTH) : blockText, + }); + + if (fullText.length > 0) fullText += '\n'; + fullText += blockText; + } + + const truncated = fullText.length > PREVIEW_TEXT_MAX_LENGTH; + return { + text: truncated ? fullText.slice(0, PREVIEW_TEXT_MAX_LENGTH) : fullText, + truncated, + blocks, + }; +} + +// --------------------------------------------------------------------------- +// Ref encoding +// --------------------------------------------------------------------------- + +/** + * Finds the nearest text-block candidate to a given position. + * Used as a fallback when a range spans only structural boundaries. + */ +function findNearestTextCandidate(index: BlockIndex, pos: number): BlockCandidate | undefined { + let best: BlockCandidate | undefined; + let bestDist = Infinity; + for (const c of index.candidates) { + if (!isTextBlockCandidate(c)) continue; + const dist = pos < c.pos ? c.pos - pos : pos > c.end ? pos - c.end : 0; + if (dist < bestDist) { + bestDist = dist; + best = c; + } + } + return best; +} + +/** + * Encodes the resolved range as a V3 text ref so it can be consumed by + * the existing delete/replace/format mutation paths. + * + * Only text-block candidates produce segments — structural containers (table, + * tableRow) are skipped because their nested text blocks provide the actual + * content segments. A fallback ensures collapsed or boundary-only ranges + * produce at least one segment when a nearby text block exists. + * + * Returns `null` when the range contains no text content at all (e.g. an + * image-only document) — encoding a ref with zero segments would produce + * a dead-on-arrival handle that fails on round-trip. + */ +function encodeRangeRef( + editor: Editor, + index: BlockIndex, + absFrom: number, + absTo: number, + revision: string, +): string | null { + const segments: Array<{ blockId: string; start: number; end: number }> = []; + + for (const candidate of index.candidates) { + if (candidate.end <= absFrom || candidate.pos >= absTo) continue; + if (!isTextBlockCandidate(candidate)) continue; + + const blockContentStart = candidate.pos + 1; + const blockContentEnd = candidate.end - 1; + const segStart = Math.max(blockContentStart, absFrom); + const segEnd = Math.min(blockContentEnd, absTo); + if (segStart > segEnd) continue; + + segments.push({ + blockId: candidate.nodeId, + start: computeTextOffset(editor, blockContentStart, segStart), + end: computeTextOffset(editor, blockContentStart, segEnd), + }); + } + + // Collapsed or boundary-only ranges may not intersect any text-block content. + // Try to find a nearby text block for a zero-width fallback segment. + if (segments.length === 0) { + const fallback = findNearestTextCandidate(index, absFrom); + if (fallback) { + const blockContentStart = fallback.pos + 1; + const clampedPos = Math.max(blockContentStart, Math.min(fallback.end - 1, absFrom)); + const offset = computeTextOffset(editor, blockContentStart, clampedPos); + segments.push({ blockId: fallback.nodeId, start: offset, end: offset }); + } + } + + // No text content exists in the document — cannot encode a valid ref. + if (segments.length === 0) { + return null; + } + + return encodeV3Ref({ + v: 3, + rev: revision, + matchId: `range:${absFrom}-${absTo}`, + scope: 'match', + segments, + }); +} + +// --------------------------------------------------------------------------- +// Coverage check +// --------------------------------------------------------------------------- + +/** + * Returns true when the V3 text ref can faithfully represent the full range. + * + * A structural candidate (table, image, etc.) that fully *contains* the range + * is a benign ancestor — e.g. a table wrapping the selected paragraph. The + * ref still faithfully encodes the text selection within it. A structural + * candidate that the range *crosses* (extends beyond its boundaries) or that + * sits alongside text blocks as a sibling makes the ref lossy. + */ +function rangeContainsOnlyTextBlocks(index: BlockIndex, absFrom: number, absTo: number): boolean { + for (const candidate of index.candidates) { + if (candidate.end <= absFrom || candidate.pos >= absTo) continue; + if (isTextBlockCandidate(candidate)) continue; + // Structural ancestor that fully wraps the range — benign. + if (candidate.pos <= absFrom && candidate.end >= absTo) continue; + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Resolves two explicit anchors into a contiguous document range. + * + * Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. + */ +export function resolveRange(editor: Editor, input: ResolveRangeInput): ResolveRangeOutput { + const revision = getRevision(editor); + + if (input.expectedRevision !== undefined) { + checkRevision(editor, input.expectedRevision); + } + + const index = getBlockIndex(editor); + + // Resolve both anchors to absolute PM positions + const rawFrom = resolveAnchor(editor, input.start, revision, index); + const rawTo = resolveAnchor(editor, input.end, revision, index); + + // Normalize to document order + const absFrom = Math.min(rawFrom, rawTo); + const absTo = Math.max(rawFrom, rawTo); + + const target = buildSelectionTarget(editor, index, absFrom, absTo); + + // The V3 text ref can only encode text-block content segments. The ref is + // lossy when the target uses nodeEdge endpoints (structural block boundaries) + // OR when structural blocks (table, image, etc.) fall within the range — even + // if both endpoints are text points. + const coversFullTarget = + target.start.kind === 'text' && target.end.kind === 'text' && rangeContainsOnlyTextBlocks(index, absFrom, absTo); + + return { + evaluatedRevision: revision, + handle: { + ref: encodeRangeRef(editor, index, absFrom, absTo, revision), + refStability: 'ephemeral', + coversFullTarget, + }, + target, + preview: buildPreview(editor, index, absFrom, absTo), + }; +} From a90dc38e130f0a3e37de8f32cdccb6d83524d09b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 13 Mar 2026 14:16:09 -0700 Subject: [PATCH 3/9] fix(document-api): fix selection-target mutation handling --- packages/document-api/src/contract/schemas.ts | 45 ++++++++----- .../plan-engine/compiler.ts | 51 ++++++++++++++- .../plan-engine/plan-wrappers.ts | 64 ++++++++++++++++--- .../plan-engine/style-resolver.ts | 2 +- 4 files changed, 135 insertions(+), 27 deletions(-) diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 49a8d164ff..413497f508 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -65,6 +65,23 @@ function ref(name: string): JsonSchema { return { $ref: `#/$defs/${name}` }; } +/** + * Builds a `oneOf` schema that merges each TargetLocator branch with additional + * payload properties. This avoids the `allOf` + `additionalProperties: false` + * conflict where each branch would reject keys defined in the other schema. + */ +function targetLocatorWithPayload( + payloadProperties: Record, + payloadRequired: readonly string[] = [], +): JsonSchema { + return { + oneOf: [ + objectSchema({ target: ref('SelectionTarget'), ...payloadProperties }, ['target', ...payloadRequired]), + objectSchema({ ref: { type: 'string' }, ...payloadProperties }, ['ref', ...payloadRequired]), + ], + }; +} + /** Shared output/success/failure shape for ImagesMutationResult operations. */ function imagesMutationSchemaSet(inputSchema: JsonSchema): OperationSchemaSet { return { @@ -1562,7 +1579,7 @@ const formatInlineAliasOperationSchemas: Record = { oneOf: [ // Text replacement: TargetLocator + text { - allOf: [targetLocatorSchema, objectSchema({ text: { type: 'string' } }, ['text'])], + ...targetLocatorWithPayload({ text: { type: 'string' } }, ['text']), }, // Structural replacement: exactly one of (target | ref) + content { - allOf: [ - // Require at least one locator — mirrors runtime validation. - { - oneOf: [ - objectSchema({ target: { oneOf: [sdAddressSchema, textAddressSchema, selectionTargetSchema] } }, [ - 'target', - ]), - objectSchema({ ref: { type: 'string' } }, ['ref']), - ], - }, + oneOf: [ objectSchema( { target: { oneOf: [sdAddressSchema, textAddressSchema, selectionTargetSchema] }, + content: { type: 'object' }, + nestingPolicy: { type: 'object' }, + }, + ['target', 'content'], + ), + objectSchema( + { ref: { type: 'string' }, content: { type: 'object' }, nestingPolicy: { type: 'object' }, }, - ['content'], + ['ref', 'content'], ), ], }, @@ -2598,7 +2613,7 @@ const operationSchemas: Record = { }, delete: { input: { - allOf: [targetLocatorSchema, objectSchema({ behavior: deleteBehaviorSchema })], + ...targetLocatorWithPayload({ behavior: deleteBehaviorSchema }), }, output: textMutationResultSchemaFor('delete'), success: textMutationSuccessSchema, @@ -2606,7 +2621,7 @@ const operationSchemas: Record = { }, 'format.apply': { input: { - allOf: [targetLocatorSchema, objectSchema({ inline: buildInlineRunPatchSchema() }, ['inline'])], + ...targetLocatorWithPayload({ inline: buildInlineRunPatchSchema() }, ['inline']), }, output: textMutationResultSchemaFor('format.apply'), success: textMutationSuccessSchema, diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts index 880fff4caf..8e64194483 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts @@ -27,7 +27,7 @@ import type { } from './executor-registry.types.js'; import { planError } from './errors.js'; import { hasStepExecutor } from './executor-registry.js'; -import { captureRunsInRange } from './style-resolver.js'; +import { captureRunsInRange, checkUniformity, type CapturedStyle } from './style-resolver.js'; import { getBlockIndex } from '../helpers/index-cache.js'; import { getRevision } from './revision-tracker.js'; import { executeTextSelector } from '../find/text-strategy.js'; @@ -778,6 +778,12 @@ function resolveTargetWhereClause(editor: Editor, step: MutationStep, where: Tar ? editor.state.doc.textBetween(absFrom, absTo, '\n', '\ufffc') : resolved.text; + // Capture inline style for style-preserving operations (mirrors buildRangeTarget logic). + const capturedStyle = + step.op === 'text.rewrite' || step.op === 'format.apply' + ? captureStyleAtAbsoluteRange(editor, absFrom, absTo) + : undefined; + return { kind: 'selection', stepId: step.id, @@ -786,9 +792,52 @@ function resolveTargetWhereClause(editor: Editor, step: MutationStep, where: Tar absTo, normalizedTarget: where.target, text, + capturedStyle, }; } +/** + * Captures inline style runs for an absolute PM position range. + * + * Walks all text blocks between `absFrom` and `absTo`, captures runs from + * each block's overlapping portion, and merges them into a single + * CapturedStyle with a continuous offset sequence. This ensures cross-block + * selections capture formatting from all blocks, not just the first. + */ +function captureStyleAtAbsoluteRange(editor: Editor, absFrom: number, absTo: number): CapturedStyle | undefined { + const doc = editor.state.doc; + const allRuns: CapturedStyle['runs'] = []; + let runOffset = 0; + + doc.nodesBetween(absFrom, absTo, (node, pos) => { + if (!node.isTextblock) return true; + + const blockEnd = pos + node.nodeSize; + const overlapFrom = Math.max(absFrom, pos + 1); + const overlapTo = Math.min(absTo, blockEnd - 1); + if (overlapFrom >= overlapTo) return false; + + const relFrom = overlapFrom - pos - 1; + const relTo = overlapTo - pos - 1; + const captured = captureRunsInRange(editor, pos, relFrom, relTo); + + for (const run of captured.runs) { + allRuns.push({ + ...run, + from: runOffset + (run.from - relFrom), + to: runOffset + (run.to - relFrom), + }); + } + runOffset += relTo - relFrom; + + return false; // Don't descend into inline content (captureRunsInRange handles that) + }); + + if (allRuns.length === 0) return undefined; + + return { runs: allRuns, isUniform: checkUniformity(allRuns) }; +} + // --------------------------------------------------------------------------- // Step target resolution // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 06a8afccbb..20ec4d722c 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -41,7 +41,7 @@ import { import type { Editor } from '../../core/Editor.js'; import type { CompiledPlan } from './compiler.js'; import { compilePlan } from './compiler.js'; -import type { CompiledTarget, CompiledSelectionTarget } from './executor-registry.types.js'; +import type { CompiledTarget, CompiledSpanTarget } from './executor-registry.types.js'; import { executeCompiledPlan } from './executor.js'; import { checkRevision, getRevision } from './revision-tracker.js'; import { compoundMutation } from '../../core/parts/mutation/compound-mutation.js'; @@ -711,6 +711,13 @@ function selectionTargetToResolution( }; } +/** Fallback resolution when no target data is available. */ +const EMPTY_RESOLUTION: TextMutationResolution = { + target: { kind: 'text', blockId: '', range: { start: 0, end: 0 } }, + range: { from: 0, to: 0 }, + text: '', +}; + /** * Builds a TextMutationResolution directly from the compiled plan's * CompiledSelectionTarget. This produces correct resolution data @@ -721,11 +728,13 @@ function buildSelectionResolutionFromCompiled(compiled: CompiledPlan, stepId: st const target = compiledStep?.targets[0]; if (target?.kind === 'selection') { - const sel = target as CompiledSelectionTarget; - return selectionTargetToResolution(sel.normalizedTarget, { from: sel.absFrom, to: sel.absTo }, sel.text); + return selectionTargetToResolution( + target.normalizedTarget, + { from: target.absFrom, to: target.absTo }, + target.text, + ); } - // Fallback for non-selection targets (ref-based resolution). if (target?.kind === 'range') { return { target: { kind: 'text', blockId: target.blockId, range: { start: target.from, end: target.to } }, @@ -734,10 +743,35 @@ function buildSelectionResolutionFromCompiled(compiled: CompiledPlan, stepId: st }; } + if (target?.kind === 'span') { + return spanTargetToResolution(target); + } + + return EMPTY_RESOLUTION; +} + +/** Converts a CompiledSpanTarget to a TextMutationResolution using its segments. */ +function spanTargetToResolution(target: CompiledSpanTarget): TextMutationResolution { + const first = target.segments[0]; + const last = target.segments[target.segments.length - 1]; + if (!first || !last) { + return EMPTY_RESOLUTION; + } + + const isCrossBlock = first.blockId !== last.blockId; + const selectionTarget: SelectionTarget | undefined = isCrossBlock + ? { + kind: 'selection', + start: { kind: 'text', blockId: first.blockId, offset: first.from }, + end: { kind: 'text', blockId: last.blockId, offset: last.to }, + } + : undefined; + return { - target: { kind: 'text', blockId: '', range: { start: 0, end: 0 } }, - range: { from: 0, to: 0 }, - text: '', + target: { kind: 'text', blockId: first.blockId, range: { start: first.from, end: first.to } }, + range: { from: first.absFrom, to: last.absTo }, + text: target.text, + ...(selectionTarget ? { selectionTarget } : undefined), }; } @@ -1331,11 +1365,19 @@ interface ExpandedBlockRange { lastBlock: BlockCandidate; } +/** Container node types that should not be used as block boundaries — they + * enclose child blocks and would cause the expansion to swallow entire tables. */ +const CONTAINER_NODE_TYPES: ReadonlySet = new Set(['table', 'tableRow', 'tableCell']); + /** * Expands a PM position range to encompass full block boundaries. - * Finds the first block whose range intersects `absFrom` and the last - * block whose range intersects `absTo`, then returns their outer boundaries - * plus the block IDs needed for receipt metadata. + * Finds the first content-level block whose range intersects `absFrom` and + * the last content-level block whose range intersects `absTo`, then returns + * their outer boundaries plus the block IDs needed for receipt metadata. + * + * Container nodes (table, tableRow, tableCell) are excluded so that a + * selection inside a table cell expands only to the cell's leaf blocks, + * not to the entire table. */ function expandToBlockBoundaries(index: BlockIndex, absFrom: number, absTo: number): ExpandedBlockRange { let blockFrom = absFrom; @@ -1344,6 +1386,7 @@ function expandToBlockBoundaries(index: BlockIndex, absFrom: number, absTo: numb let lastBlock: BlockCandidate | undefined; for (const candidate of index.candidates) { + if (CONTAINER_NODE_TYPES.has(candidate.nodeType)) continue; // Skip non-overlapping blocks. if (candidate.end <= absFrom || candidate.pos >= absTo) continue; if (candidate.pos <= blockFrom) { @@ -1370,6 +1413,7 @@ const VALID_EDGE_NODE_TYPES: ReadonlySet = new Set Date: Fri, 13 Mar 2026 14:55:21 -0700 Subject: [PATCH 4/9] chore: type fix --- .../layout-engine/painters/dom/tsconfig.json | 6 +- .../document-api-adapters/write-adapter.ts | 165 +++++------------- 2 files changed, 44 insertions(+), 127 deletions(-) diff --git a/packages/layout-engine/painters/dom/tsconfig.json b/packages/layout-engine/painters/dom/tsconfig.json index fc5e61ead2..a39ec89772 100644 --- a/packages/layout-engine/painters/dom/tsconfig.json +++ b/packages/layout-engine/painters/dom/tsconfig.json @@ -9,5 +9,9 @@ "lib": ["DOM", "ES2021"] }, "include": ["src/**/*.ts"], - "references": [{ "path": "../../contracts/tsconfig.json" }, { "path": "../../measuring/dom/tsconfig.json" }] + "references": [ + { "path": "../../contracts/tsconfig.json" }, + { "path": "../../measuring/dom/tsconfig.json" }, + { "path": "../../../../shared/common/tsconfig.json" } + ] } diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index 447c6ea6dd..6355931e94 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -15,46 +15,17 @@ import { insertParagraphAtEnd, resolveWriteTarget, type ResolvedWrite } from './ import { toCanonicalTrackedChangeId } from './helpers/tracked-change-resolver.js'; function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWrite): ReceiptFailure | null { - if (request.kind === 'insert') { - if (!request.text) { - return { - code: 'INVALID_TARGET', - message: 'Insert operations require non-empty text.', - }; - } - - if (resolvedTarget.range.from !== resolvedTarget.range.to) { - return { - code: 'INVALID_TARGET', - message: 'Insert operations require a collapsed target range.', - }; - } - - return null; - } - - if (request.kind === 'replace') { - if (request.text == null || request.text.length === 0) { - return { - code: 'INVALID_TARGET', - message: 'Replace operations require non-empty text. Use delete for removals.', - }; - } - - if (resolvedTarget.resolution.text === request.text) { - return { - code: 'NO_OP', - message: 'Replace operation produced no change.', - }; - } - - return null; + if (!request.text) { + return { + code: 'INVALID_TARGET', + message: 'Insert operations require non-empty text.', + }; } - if (resolvedTarget.range.from === resolvedTarget.range.to) { + if (resolvedTarget.range.from !== resolvedTarget.range.to) { return { - code: 'NO_OP', - message: 'Delete operation produced no change for a collapsed range.', + code: 'INVALID_TARGET', + message: 'Insert operations require a collapsed target range.', }; } @@ -65,105 +36,48 @@ function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWri * Normalize block-relative locator fields into a canonical TextAddress. * This runs inside the adapter layer so that the resolution uses engine-specific block lookup. * - * - Insert: blockId + offset → collapsed TextAddress - * - Replace/Delete: blockId + start + end → ranged TextAddress + * Insert: blockId + offset → collapsed TextAddress * * Returns the original request unchanged when no friendly locator is present. */ function normalizeWriteLocator(request: WriteRequest): WriteRequest { - if (request.kind === 'insert') { - const hasBlockId = request.blockId !== undefined; - const hasOffset = request.offset !== undefined; - - // Defensive: reject offset mixed with canonical target. - if (hasOffset && request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { - fields: ['target', 'offset'], - }); - } - - // Defensive: reject orphaned offset without blockId (safety net for direct adapter callers). - if (hasOffset && !hasBlockId) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { - fields: ['offset', 'blockId'], - }); - } - - if (!hasBlockId) return request; - - // Defensive: reject mixed locator modes at adapter boundary (safety net). - if (request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { - fields: ['target', 'blockId'], - }); - } - - const effectiveOffset = request.offset ?? 0; - const target: TextAddress = { - kind: 'text', - blockId: request.blockId!, - range: { start: effectiveOffset, end: effectiveOffset }, - }; + const hasBlockId = request.blockId !== undefined; + const hasOffset = request.offset !== undefined; - return { kind: 'insert', target, text: request.text }; + // Defensive: reject offset mixed with canonical target. + if (hasOffset && request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { + fields: ['target', 'offset'], + }); } - // replace / delete: range normalization (blockId + start + end → TextAddress) - if (request.kind === 'replace' || request.kind === 'delete') { - const hasBlockId = request.blockId !== undefined; - const hasStart = request.start !== undefined; - const hasEnd = request.end !== undefined; - - // Defensive: reject range fields mixed with canonical target. - if (request.target && (hasBlockId || hasStart || hasEnd)) { - throw new DocumentApiAdapterError( - 'INVALID_TARGET', - `Cannot combine target with blockId/start/end on ${request.kind} request.`, - { fields: ['target', 'blockId', 'start', 'end'] }, - ); - } - - // Defensive: reject orphaned start/end without blockId. - if (!hasBlockId && (hasStart || hasEnd)) { - throw new DocumentApiAdapterError('INVALID_TARGET', `start/end require blockId on ${request.kind} request.`, { - fields: ['blockId', 'start', 'end'], - }); - } - - if (!hasBlockId) return request; - - // Defensive: reject incomplete range. - if (!hasStart || !hasEnd) { - throw new DocumentApiAdapterError( - 'INVALID_TARGET', - `blockId requires both start and end on ${request.kind} request.`, - { fields: ['blockId', 'start', 'end'] }, - ); - } + // Defensive: reject orphaned offset without blockId (safety net for direct adapter callers). + if (hasOffset && !hasBlockId) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { + fields: ['offset', 'blockId'], + }); + } - const target: TextAddress = { - kind: 'text', - blockId: request.blockId!, - range: { start: request.start!, end: request.end! }, - }; + if (!hasBlockId) return request; - // Construct clean canonical objects — no leftover friendly fields. - if (request.kind === 'replace') { - return { kind: 'replace', target, text: request.text }; - } - return { kind: 'delete', target, text: '' }; + // Defensive: reject mixed locator modes at adapter boundary (safety net). + if (request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { + fields: ['target', 'blockId'], + }); } - return request; + const effectiveOffset = request.offset ?? 0; + const target: TextAddress = { + kind: 'text', + blockId: request.blockId!, + range: { start: effectiveOffset, end: effectiveOffset }, + }; + + return { kind: 'insert', target, text: request.text }; } function applyDirectWrite(editor: Editor, request: WriteRequest, resolvedTarget: ResolvedWrite): TextMutationReceipt { - if (request.kind === 'delete') { - const tr = applyDirectMutationMeta(editor.state.tr.delete(resolvedTarget.range.from, resolvedTarget.range.to)); - editor.dispatch(tr); - return { success: true, resolution: resolvedTarget.resolution }; - } - // Structural-end: create a paragraph at the document end, since raw // insertText cannot place text between block nodes. if (resolvedTarget.structuralEnd) { @@ -171,7 +85,7 @@ function applyDirectWrite(editor: Editor, request: WriteRequest, resolvedTarget: return { success: true, resolution: resolvedTarget.resolution }; } - // text is guaranteed non-empty for insert/replace after validateWriteRequest + // text is guaranteed non-empty for insert after validateWriteRequest const tr = applyDirectMutationMeta( editor.state.tr.insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to), ); @@ -192,13 +106,12 @@ function applyTrackedWrite(editor: Editor, request: WriteRequest, resolvedTarget // insertTrackedChange is guaranteed to exist after ensureTrackedCapability. const insertTrackedChange = editor.commands!.insertTrackedChange!; - const text = request.kind === 'delete' ? '' : (request.text ?? ''); const changeId = uuidv4(); const didApply = insertTrackedChange({ from: resolvedTarget.range.from, - to: request.kind === 'insert' ? resolvedTarget.range.from : resolvedTarget.range.to, - text, + to: resolvedTarget.range.from, + text: request.text ?? '', id: changeId, }); From a0849c76e04e0728f250bdfb42014b179756e296 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 13 Mar 2026 17:23:12 -0700 Subject: [PATCH 5/9] fix(plan-engine): repair selection mutation execution and receipt resolution --- .../document-api-adapters/plan-engine/executor.ts | 13 ++++++++++--- .../plan-engine/plan-wrappers.ts | 13 +++++-------- .../plan-engine/register-executors.ts | 8 +++++++- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts index 551b454ccf..106d832383 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts @@ -76,10 +76,17 @@ function resolveMarksForRange(editor: Editor, target: CompiledRangeTarget, step: const rewriteStep = step as TextRewriteStep; const policy = rewriteStep.args.style?.inline ?? DEFAULT_INLINE_POLICY; - const captured = - target.capturedStyle ?? - captureRunsInRange(editor, toAbsoluteBlockPos(editor, target.blockId), target.from, target.to); + // capturedStyle is populated at compile time for selection targets. + // Fall back to live capture only for range targets with a real blockId. + if (target.capturedStyle) { + return resolveInlineStyle(editor, target.capturedStyle, policy, step.id); + } + + // Synthetic blockId ('__selection__') means both selection endpoints were + // nodeEdge anchors with no text block — no inline style to preserve. + if (target.blockId === '__selection__') return []; + const captured = captureRunsInRange(editor, toAbsoluteBlockPos(editor, target.blockId), target.from, target.to); return resolveInlineStyle(editor, captured, policy, step.id); } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 20ec4d722c..4cb7c0f4aa 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -788,7 +788,8 @@ function buildSelectionResolutionFromOutcome( if (stepOutcome?.data) { const data = stepOutcome.data; - // Prefer selection-aware resolutions when available. + // Prefer selection-aware resolutions when available — these carry + // absolute ranges and full SelectionTarget metadata. if ( 'selectionResolutions' in data && Array.isArray(data.selectionResolutions) && @@ -798,13 +799,9 @@ function buildSelectionResolutionFromOutcome( return selectionTargetToResolution(selRes.selectionTarget, selRes.range, selRes.text); } - // Fall back to plain range resolutions. - if ('resolutions' in data && Array.isArray(data.resolutions) && data.resolutions.length > 0) { - const planRes = data.resolutions[0] as TextMutationResolution; - if (planRes.target?.blockId !== '__selection__') { - return planRes; - } - } + // Skip data.resolutions — TextStepResolution.range carries block-relative + // offsets, but TextMutationResolution.range requires absolute document + // positions. The compiled target fallback always has correct absolute ranges. } // Fall back to the compiled target data, which is always correct. diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts index 270289ddd4..49f746843f 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts @@ -104,7 +104,13 @@ import { */ function selectionTargetToRange(t: CompiledSelectionTarget): CompiledRangeTarget { const startPoint = t.normalizedTarget.start; - const blockId = startPoint.kind === 'text' ? startPoint.blockId : '__selection__'; + const endPoint = t.normalizedTarget.end; + + // Derive a real blockId from the nearest text point so fallback lookups + // (e.g. style capture in resolveMarksForRange) never hit a synthetic id. + const blockId = + startPoint.kind === 'text' ? startPoint.blockId : endPoint.kind === 'text' ? endPoint.blockId : '__selection__'; + return { kind: 'range', stepId: t.stepId, From ad67ae3e57a0c383e177bfae70288d47d1876911 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 14 Mar 2026 13:30:10 -0700 Subject: [PATCH 6/9] chore: test fixes --- .../src/__tests__/conformance/scenarios.ts | 2 + .../src/__tests__/lib/invoke-input.test.ts | 74 ++++++++ .../__tests__/schema-ref-resolution.test.ts | 74 ++++++++ apps/cli/src/cli/operation-params.ts | 129 ++++++++++++- apps/cli/src/lib/invoke-input.ts | 94 ++++++++-- apps/cli/src/lib/operation-args.ts | 24 +++ .../plan-engine/executor.ts | 4 +- .../plan-engine/plan-wrappers.ts | 54 +++++- .../document-api-adapters/write-adapter.ts | 176 ++++++++++++++---- 9 files changed, 572 insertions(+), 59 deletions(-) create mode 100644 apps/cli/src/__tests__/lib/invoke-input.test.ts diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 2b89ad836b..dabdbc036a 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -408,6 +408,8 @@ function sampleInlineAliasValue(key: InlineAliasKey): unknown { case 'fontSize': case 'fontSizeCs': return 14; + case 'fontFamily': + return 'Courier New'; case 'letterSpacing': return 0.5; case 'position': diff --git a/apps/cli/src/__tests__/lib/invoke-input.test.ts b/apps/cli/src/__tests__/lib/invoke-input.test.ts new file mode 100644 index 0000000000..bf30597b75 --- /dev/null +++ b/apps/cli/src/__tests__/lib/invoke-input.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test'; +import { extractInvokeInput } from '../../lib/invoke-input'; +import { CliError } from '../../lib/errors'; + +describe('extractInvokeInput', () => { + test('converts replace flat range flags into a single-block SelectionTarget', () => { + const input = extractInvokeInput('replace', { + doc: 'fixture.docx', + blockId: 'p1', + start: 2, + end: 5, + text: 'Updated', + }) as Record; + + expect(input).toEqual({ + text: 'Updated', + target: { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 2 }, + end: { kind: 'text', blockId: 'p1', offset: 5 }, + }, + }); + }); + + test('upgrades legacy TextAddress target-json input for format.apply', () => { + const input = extractInvokeInput('format.apply', { + target: { + kind: 'text', + blockId: 'p1', + range: { start: 0, end: 4 }, + }, + inline: { bold: true }, + }) as Record; + + expect(input).toEqual({ + target: { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 0 }, + end: { kind: 'text', blockId: 'p1', offset: 4 }, + }, + inline: { bold: true }, + }); + }); + + test('preserves text-address targets for comments.create', () => { + const input = extractInvokeInput('comments.create', { + blockId: 'p1', + start: 1, + end: 3, + text: 'Review this', + }) as Record; + + expect(input).toEqual({ + text: 'Review this', + target: { + kind: 'text', + blockId: 'p1', + range: { start: 1, end: 3 }, + }, + }); + }); + + test('rejects collapsed legacy text ranges for format operations', () => { + expect(() => + extractInvokeInput('format.bold', { + target: { + kind: 'text', + blockId: 'p1', + range: { start: 2, end: 2 }, + }, + }), + ).toThrow(CliError); + }); +}); diff --git a/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts b/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts index e103c570fe..e1daa73a10 100644 --- a/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts +++ b/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts @@ -168,6 +168,80 @@ describe('operation-params deriveParamsFromInputSchema with $ref', () => { expect(modeParam).toBeDefined(); expect(modeParam!.type).toBe('string'); // enum → string }); + + test('derives params from top-level allOf by merging object members', () => { + const inputSchema = { + allOf: [ + { + oneOf: [ + { + type: 'object', + properties: { + target: { $ref: '#/$defs/TextAddress' }, + }, + required: ['target'], + }, + { + type: 'object', + properties: { + ref: { type: 'string' }, + }, + required: ['ref'], + }, + ], + }, + { + type: 'object', + properties: { + text: { type: 'string' }, + }, + required: ['text'], + }, + ], + }; + const { params } = deriveParamsFromInputSchema(inputSchema, $defs); + const paramNames = params.map((param) => param.name); + + expect(paramNames).toContain('target'); + expect(paramNames).toContain('ref'); + expect(paramNames).toContain('text'); + expect(params.find((param) => param.name === 'text')?.required).toBe(true); + expect(params.find((param) => param.name === 'target')?.required).toBe(false); + expect(params.find((param) => param.name === 'ref')?.required).toBe(false); + }); + + test('merges duplicate properties across oneOf branches into a single param schema', () => { + const inputSchema = { + oneOf: [ + { + type: 'object', + properties: { + target: { $ref: '#/$defs/TextAddress' }, + text: { type: 'string' }, + }, + required: ['target', 'text'], + }, + { + type: 'object', + properties: { + target: { + oneOf: [{ $ref: '#/$defs/TextAddress' }, { type: 'string' }], + }, + content: { type: 'object' }, + }, + required: ['target', 'content'], + }, + ], + }; + const { params } = deriveParamsFromInputSchema(inputSchema, $defs); + const targetParam = params.find((param) => param.name === 'target'); + + expect(targetParam).toBeDefined(); + expect(targetParam!.type).toBe('json'); + expect((targetParam!.schema as { oneOf: CliTypeSpec[] }).oneOf.length).toBeGreaterThan(1); + expect(params.find((param) => param.name === 'text')).toBeDefined(); + expect(params.find((param) => param.name === 'content')).toBeDefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index 2be473b974..417243d4b9 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -78,6 +78,11 @@ const USER_EMAIL_PARAM: CliOperationParamSpec = { type JsonSchema = Record; const AGENT_HIDDEN_PARAM_NAMES = new Set(['out']); +type ObjectSchemaVariant = { + properties: Record; + required: Set; +}; + function resolveRef(schema: JsonSchema, $defs?: Record): JsonSchema { if (schema.$ref && $defs) { const prefix = '#/$defs/'; @@ -90,6 +95,108 @@ function resolveRef(schema: JsonSchema, $defs?: Record): Jso return schema; } +function hasObjectShape(schema: JsonSchema): boolean { + return schema.type === 'object' || schema.properties != null || schema.required != null; +} + +function cloneVariant(variant: ObjectSchemaVariant): ObjectSchemaVariant { + return { + properties: { ...variant.properties }, + required: new Set(variant.required), + }; +} + +function directObjectVariant(schema: JsonSchema): ObjectSchemaVariant { + return { + properties: { + ...(((schema.properties as Record | undefined) ?? {}) as Record), + }, + required: new Set(((schema.required as string[] | undefined) ?? []) as string[]), + }; +} + +function schemasEqual(left: JsonSchema, right: JsonSchema): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function mergePropertySchemas(left: JsonSchema, right: JsonSchema): JsonSchema { + if (schemasEqual(left, right)) return left; + + const variants: JsonSchema[] = []; + const appendVariant = (schema: JsonSchema) => { + if (variants.some((candidate) => schemasEqual(candidate, schema))) return; + variants.push(schema); + }; + + if (Array.isArray(left.oneOf)) { + for (const entry of left.oneOf as JsonSchema[]) appendVariant(entry); + } else { + appendVariant(left); + } + + if (Array.isArray(right.oneOf)) { + for (const entry of right.oneOf as JsonSchema[]) appendVariant(entry); + } else { + appendVariant(right); + } + + return variants.length === 1 ? variants[0]! : { oneOf: variants }; +} + +function mergeObjectVariants(left: ObjectSchemaVariant, right: ObjectSchemaVariant): ObjectSchemaVariant { + const merged = cloneVariant(left); + for (const [name, schema] of Object.entries(right.properties)) { + const existing = merged.properties[name]; + merged.properties[name] = existing ? mergePropertySchemas(existing, schema) : schema; + } + for (const key of right.required) { + merged.required.add(key); + } + return merged; +} + +function extractObjectSchemaVariants(rawSchema: JsonSchema, $defs?: Record): ObjectSchemaVariant[] { + const schema = resolveRef(rawSchema, $defs); + const directVariants = hasObjectShape(schema) ? [directObjectVariant(schema)] : []; + let variants = directVariants.length > 0 ? directVariants.map(cloneVariant) : []; + + if (Array.isArray(schema.allOf)) { + variants = variants.length > 0 ? variants : [{ properties: {}, required: new Set() }]; + for (const member of schema.allOf as JsonSchema[]) { + const memberVariants = extractObjectSchemaVariants(member, $defs); + if (memberVariants.length === 0) continue; + + const nextVariants: ObjectSchemaVariant[] = []; + for (const base of variants) { + for (const part of memberVariants) { + nextVariants.push(mergeObjectVariants(base, part)); + } + } + variants = nextVariants; + } + } + + const alternativeKeyword = Array.isArray(schema.oneOf) ? 'oneOf' : Array.isArray(schema.anyOf) ? 'anyOf' : null; + if (alternativeKeyword) { + const branches = (schema[alternativeKeyword] as JsonSchema[]).flatMap((member) => + extractObjectSchemaVariants(member, $defs), + ); + if (branches.length > 0) { + const baseVariants = variants.length > 0 ? variants : [{ properties: {}, required: new Set() }]; + const nextVariants: ObjectSchemaVariant[] = []; + for (const base of baseVariants) { + for (const branch of branches) { + nextVariants.push(mergeObjectVariants(base, branch)); + } + } + variants = nextVariants; + } + } + + if (variants.length > 0) return variants; + return hasObjectShape(schema) ? [directObjectVariant(schema)] : []; +} + function schemaToParamType(schema: JsonSchema, $defs?: Record): CliOperationParamSpec['type'] { schema = resolveRef(schema, $defs); if (schema.type === 'string') return 'string'; @@ -167,8 +274,26 @@ function deriveParamsFromInputSchema( } { const params: CliOperationParamSpec[] = []; const positionalParams: string[] = []; - const properties = (inputSchema.properties ?? {}) as Record; - const required = new Set((inputSchema.required as string[]) ?? []); + const variants = extractObjectSchemaVariants(inputSchema, $defs); + const properties: Record = {}; + const requiredCounts = new Map(); + + for (const variant of variants) { + for (const [name, schema] of Object.entries(variant.properties)) { + const existing = properties[name]; + properties[name] = existing ? mergePropertySchemas(existing, schema) : schema; + } + for (const name of variant.required) { + requiredCounts.set(name, (requiredCounts.get(name) ?? 0) + 1); + } + } + + const required = new Set(); + for (const [name] of Object.entries(properties)) { + if (variants.length > 0 && requiredCounts.get(name) === variants.length) { + required.add(name); + } + } for (const [name, rawPropSchema] of Object.entries(properties)) { const propSchema = resolveRef(rawPropSchema, $defs); diff --git a/apps/cli/src/lib/invoke-input.ts b/apps/cli/src/lib/invoke-input.ts index 4b265bdb54..c481588b79 100644 --- a/apps/cli/src/lib/invoke-input.ts +++ b/apps/cli/src/lib/invoke-input.ts @@ -12,6 +12,7 @@ * receives the correct input shape. */ +import { CliError } from './errors.js'; import { CLI_DOC_OPERATIONS, type CliExposedOperationId } from '../cli/operation-set.js'; /** @@ -88,19 +89,20 @@ const FORMAT_TARGET_OPERATIONS = CLI_DOC_OPERATIONS.filter((operationId): operat // --------------------------------------------------------------------------- /** - * Operations that accept a text-range target (textAddressSchema): + * Operations that accept a SelectionTarget or a mutation-ready `ref`. + * The CLI still supports legacy single-block text range flags/JSON inputs and + * upgrades them to the equivalent SelectionTarget before dispatch. + */ +const SELECTION_TARGET_OPERATIONS = new Set(['replace', 'delete', ...FORMAT_TARGET_OPERATIONS]); + +/** + * Operations that still accept a text-range target (textAddressSchema): * target: { kind: 'text', blockId, range: { start, end } } * * When the CLI input has flat `blockId` + `start` + `end` but no `target`, * these are folded into a canonical target object. */ -const TEXT_TARGET_OPERATIONS = new Set([ - 'replace', - 'delete', - ...FORMAT_TARGET_OPERATIONS, - 'comments.create', - 'comments.patch', -]); +const TEXT_ADDRESS_TARGET_OPERATIONS = new Set(['comments.create', 'comments.patch']); /** * Insert is a text-range operation but uses `offset` instead of `start`/`end` @@ -129,6 +131,50 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isTextAddressLike(value: unknown): value is { + kind: 'text'; + blockId: string; + range: { start: number; end: number }; +} { + if (!isRecord(value) || value.kind !== 'text' || typeof value.blockId !== 'string') return false; + if (!isRecord(value.range)) return false; + return typeof value.range.start === 'number' && typeof value.range.end === 'number'; +} + +function textAddressToSelectionTarget(target: { + blockId: string; + range: { start: number; end: number }; +}): Record { + return { + kind: 'selection', + start: { + kind: 'text', + blockId: target.blockId, + offset: target.range.start, + }, + end: { + kind: 'text', + blockId: target.blockId, + offset: target.range.end, + }, + }; +} + +function isCollapsedTextAddress(target: { range: { start: number; end: number } }): boolean { + return target.range.start === target.range.end; +} + +function assertLegacySelectionTargetSupported( + operationId: CliExposedOperationId, + target: { + range: { start: number; end: number }; + }, +): void { + if (operationId.startsWith('format.') && isCollapsedTextAddress(target)) { + throw new CliError('INVALID_ARGUMENT', `${operationId} requires a non-collapsed target range.`); + } +} + /** * Normalizes flat CLI flags into canonical `target` objects. * @@ -139,11 +185,35 @@ function isRecord(value: unknown): value is Record { function normalizeFlatTargetFlags(operationId: CliExposedOperationId, apiInput: unknown): unknown { if (!isRecord(apiInput)) return apiInput; - // Skip if target is already provided - if (apiInput.target !== undefined) return apiInput; + if (apiInput.target !== undefined) { + if (SELECTION_TARGET_OPERATIONS.has(operationId) && isTextAddressLike(apiInput.target)) { + assertLegacySelectionTargetSupported(operationId, apiInput.target); + return { + ...apiInput, + target: textAddressToSelectionTarget(apiInput.target), + }; + } + return apiInput; + } + + // --- Selection-based text mutations (replace, delete, format.*) --- + if (SELECTION_TARGET_OPERATIONS.has(operationId)) { + const blockId = apiInput.blockId; + if (typeof blockId === 'string') { + const start = typeof apiInput.start === 'number' ? apiInput.start : 0; + const end = typeof apiInput.end === 'number' ? apiInput.end : 0; + assertLegacySelectionTargetSupported(operationId, { range: { start, end } }); + const { blockId: _, start: _s, end: _e, ...rest } = apiInput; + return { + ...rest, + target: textAddressToSelectionTarget({ blockId, range: { start, end } }), + }; + } + return apiInput; + } - // --- Text-range operations (replace, delete, format.apply, comments.create, comments.patch) --- - if (TEXT_TARGET_OPERATIONS.has(operationId)) { + // --- Text-address operations (comments.create, comments.patch) --- + if (TEXT_ADDRESS_TARGET_OPERATIONS.has(operationId)) { const blockId = apiInput.blockId; if (typeof blockId === 'string') { const start = typeof apiInput.start === 'number' ? apiInput.start : 0; diff --git a/apps/cli/src/lib/operation-args.ts b/apps/cli/src/lib/operation-args.ts index 30000d9c1a..41740f2843 100644 --- a/apps/cli/src/lib/operation-args.ts +++ b/apps/cli/src/lib/operation-args.ts @@ -80,6 +80,26 @@ function isPresent(value: unknown): boolean { return true; } +function isTextAddressLike(value: unknown): value is { + kind: 'text'; + blockId: string; + range: { start: number; end: number }; +} { + if (!isRecord(value) || value.kind !== 'text' || typeof value.blockId !== 'string') return false; + if (!isRecord(value.range)) return false; + return typeof value.range.start === 'number' && typeof value.range.end === 'number'; +} + +function acceptsLegacyTextAddressTarget( + operationId: CliOperationId, + param: CliOperationParamSpec, + value: unknown, +): boolean { + if (param.name !== 'target' || !isTextAddressLike(value)) return false; + const docApiId = toDocApiId(operationId); + return docApiId === 'replace' || docApiId === 'delete' || docApiId?.startsWith('format.') === true; +} + export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec, path: string): void { if ('const' in schema) { if (value !== schema.const) { @@ -416,6 +436,9 @@ export function validateOperationInputData(operationId: CliOperationId, input: u if (!isPresent(value)) continue; if ('schema' in param && param.schema) { + if (acceptsLegacyTextAddressTarget(operationId, param, value)) { + continue; + } validateValueAgainstTypeSpec(value, param.schema, `${commandName}:input.${param.name}`); continue; } @@ -478,6 +501,7 @@ export function parseOperationArgs( if (!('schema' in param) || !param.schema) continue; const value = argsRecord[param.name]; if (!isPresent(value)) continue; + if (acceptsLegacyTextAddressTarget(operationId, param, value)) continue; validateValueAgainstTypeSpec(value, param.schema, `${commandName}:${param.name}`); } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts index 106d832383..05af4111eb 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts @@ -128,7 +128,7 @@ function buildMarksFromSetMarks(editor: Editor, setMarks?: SetMarks): readonly P // --------------------------------------------------------------------------- type InlineRunPatch = StyleApplyInput['inline']; -type TextStylePatchKey = 'color' | 'fontSize' | 'letterSpacing' | 'vertAlign' | 'position'; +type TextStylePatchKey = 'color' | 'fontSize' | 'fontFamily' | 'letterSpacing' | 'vertAlign' | 'position'; type TextStylePatch = Partial> & { /** Derived from `caps` boolean — mapped to the textStyle mark's `textTransform` attribute. */ textTransform?: string | null; @@ -145,7 +145,7 @@ interface OverlappingRun { } const BOOLEAN_INLINE_MARK_KEYS = ['bold', 'italic', 'strike'] as const; -const TEXT_STYLE_KEYS = ['color', 'fontSize', 'letterSpacing', 'vertAlign', 'position'] as const; +const TEXT_STYLE_KEYS = ['color', 'fontSize', 'fontFamily', 'letterSpacing', 'vertAlign', 'position'] as const; const PRESERVE_RUN_PROPERTIES_META_KEY = 'sdPreserveRunPropertiesKeys'; function isRecord(value: unknown): value is Record { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 4cb7c0f4aa..02fc42aeba 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -1245,7 +1245,10 @@ function resolveStructuralLocator(editor: Editor, input: SDReplaceInput): Resolv // Expand to full block boundaries for structural replace. const index = getBlockIndex(editor); - const expanded = expandToBlockBoundaries(index, resolved.absFrom, resolved.absTo); + const expanded = expandToBlockBoundaries(index, resolved.absFrom, resolved.absTo, { + startHint: resolveSelectionBoundaryHint(index, sel.start), + endHint: resolveSelectionBoundaryHint(index, sel.end), + }); const textTarget: TextAddress = { kind: 'text', @@ -1362,10 +1365,38 @@ interface ExpandedBlockRange { lastBlock: BlockCandidate; } +interface BoundaryHints { + startHint?: BlockCandidate; + endHint?: BlockCandidate; +} + /** Container node types that should not be used as block boundaries — they * enclose child blocks and would cause the expansion to swallow entire tables. */ const CONTAINER_NODE_TYPES: ReadonlySet = new Set(['table', 'tableRow', 'tableCell']); +function resolveSelectionBoundaryHint(index: BlockIndex, point: SelectionPoint): BlockCandidate | undefined { + if (point.kind !== 'nodeEdge') return undefined; + + const key = `${point.node.nodeType}:${point.node.nodeId}`; + if (index.ambiguous.has(key)) { + throw new DocumentApiAdapterError('AMBIGUOUS_TARGET', `Multiple blocks share key "${key}".`, { + nodeType: point.node.nodeType, + nodeId: point.node.nodeId, + }); + } + + const candidate = index.byId.get(key); + if (!candidate) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Node "${point.node.nodeType}" with id "${point.node.nodeId}" not found.`, + { nodeType: point.node.nodeType, nodeId: point.node.nodeId }, + ); + } + + return candidate; +} + /** * Expands a PM position range to encompass full block boundaries. * Finds the first content-level block whose range intersects `absFrom` and @@ -1376,21 +1407,28 @@ const CONTAINER_NODE_TYPES: ReadonlySet = new Set(['table', 'tableRow', * selection inside a table cell expands only to the cell's leaf blocks, * not to the entire table. */ -function expandToBlockBoundaries(index: BlockIndex, absFrom: number, absTo: number): ExpandedBlockRange { - let blockFrom = absFrom; - let blockTo = absTo; - let firstBlock: BlockCandidate | undefined; - let lastBlock: BlockCandidate | undefined; +function expandToBlockBoundaries( + index: BlockIndex, + absFrom: number, + absTo: number, + hints?: BoundaryHints, +): ExpandedBlockRange { + let blockFrom = hints?.startHint?.pos ?? absFrom; + let blockTo = hints?.endHint?.end ?? absTo; + let firstBlock: BlockCandidate | undefined = hints?.startHint; + let lastBlock: BlockCandidate | undefined = hints?.endHint; + const lockStart = firstBlock !== undefined; + const lockEnd = lastBlock !== undefined; for (const candidate of index.candidates) { if (CONTAINER_NODE_TYPES.has(candidate.nodeType)) continue; // Skip non-overlapping blocks. if (candidate.end <= absFrom || candidate.pos >= absTo) continue; - if (candidate.pos <= blockFrom) { + if (!lockStart && candidate.pos <= blockFrom) { blockFrom = candidate.pos; firstBlock = candidate; } - if (candidate.end >= blockTo) { + if (!lockEnd && candidate.end >= blockTo) { blockTo = candidate.end; lastBlock = candidate; } diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index 6355931e94..a03a2939bc 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -14,18 +14,66 @@ import { checkRevision } from './plan-engine/revision-tracker.js'; import { insertParagraphAtEnd, resolveWriteTarget, type ResolvedWrite } from './helpers/adapter-utils.js'; import { toCanonicalTrackedChangeId } from './helpers/tracked-change-resolver.js'; -function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWrite): ReceiptFailure | null { - if (!request.text) { - return { - code: 'INVALID_TARGET', - message: 'Insert operations require non-empty text.', - }; +type LegacyReplaceWriteRequest = { + kind: 'replace'; + target?: TextAddress; + text: string; + blockId?: string; + start?: number; + end?: number; +}; + +type LegacyDeleteWriteRequest = { + kind: 'delete'; + target?: TextAddress; + blockId?: string; + start?: number; + end?: number; +}; + +type LegacyWriteRequest = WriteRequest | LegacyReplaceWriteRequest | LegacyDeleteWriteRequest; + +function validateWriteRequest(request: LegacyWriteRequest, resolvedTarget: ResolvedWrite): ReceiptFailure | null { + if (request.kind === 'insert') { + if (!request.text) { + return { + code: 'INVALID_TARGET', + message: 'Insert operations require non-empty text.', + }; + } + + if (resolvedTarget.range.from !== resolvedTarget.range.to) { + return { + code: 'INVALID_TARGET', + message: 'Insert operations require a collapsed target range.', + }; + } + + return null; } - if (resolvedTarget.range.from !== resolvedTarget.range.to) { + if (request.kind === 'replace') { + if (request.text == null || request.text.length === 0) { + return { + code: 'INVALID_TARGET', + message: 'Replace operations require non-empty text. Use delete for removals.', + }; + } + + if (resolvedTarget.resolution.text === request.text) { + return { + code: 'NO_OP', + message: 'Replace operation produced no change.', + }; + } + + return null; + } + + if (resolvedTarget.range.from === resolvedTarget.range.to) { return { - code: 'INVALID_TARGET', - message: 'Insert operations require a collapsed target range.', + code: 'NO_OP', + message: 'Delete operation produced no change for a collapsed range.', }; } @@ -36,48 +84,97 @@ function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWri * Normalize block-relative locator fields into a canonical TextAddress. * This runs inside the adapter layer so that the resolution uses engine-specific block lookup. * - * Insert: blockId + offset → collapsed TextAddress + * - Insert: blockId + offset → collapsed TextAddress + * - Replace/Delete: blockId + start + end → ranged TextAddress * * Returns the original request unchanged when no friendly locator is present. */ -function normalizeWriteLocator(request: WriteRequest): WriteRequest { +function normalizeWriteLocator(request: LegacyWriteRequest): LegacyWriteRequest { + if (request.kind === 'insert') { + const hasBlockId = request.blockId !== undefined; + const hasOffset = request.offset !== undefined; + + if (hasOffset && request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { + fields: ['target', 'offset'], + }); + } + + if (hasOffset && !hasBlockId) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { + fields: ['offset', 'blockId'], + }); + } + + if (!hasBlockId) return request; + + if (request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { + fields: ['target', 'blockId'], + }); + } + + const effectiveOffset = request.offset ?? 0; + const target: TextAddress = { + kind: 'text', + blockId: request.blockId!, + range: { start: effectiveOffset, end: effectiveOffset }, + }; + + return { kind: 'insert', target, text: request.text }; + } + const hasBlockId = request.blockId !== undefined; - const hasOffset = request.offset !== undefined; + const hasStart = request.start !== undefined; + const hasEnd = request.end !== undefined; - // Defensive: reject offset mixed with canonical target. - if (hasOffset && request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { - fields: ['target', 'offset'], - }); + if (request.target && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Cannot combine target with blockId/start/end on ${request.kind} request.`, + { fields: ['target', 'blockId', 'start', 'end'] }, + ); } - // Defensive: reject orphaned offset without blockId (safety net for direct adapter callers). - if (hasOffset && !hasBlockId) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { - fields: ['offset', 'blockId'], + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiAdapterError('INVALID_TARGET', `start/end require blockId on ${request.kind} request.`, { + fields: ['blockId', 'start', 'end'], }); } if (!hasBlockId) return request; - // Defensive: reject mixed locator modes at adapter boundary (safety net). - if (request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { - fields: ['target', 'blockId'], - }); + if (!hasStart || !hasEnd) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `blockId requires both start and end on ${request.kind} request.`, + { fields: ['blockId', 'start', 'end'] }, + ); } - const effectiveOffset = request.offset ?? 0; const target: TextAddress = { kind: 'text', blockId: request.blockId!, - range: { start: effectiveOffset, end: effectiveOffset }, + range: { start: request.start!, end: request.end! }, }; - return { kind: 'insert', target, text: request.text }; + if (request.kind === 'replace') { + return { kind: 'replace', target, text: request.text }; + } + return { kind: 'delete', target }; } -function applyDirectWrite(editor: Editor, request: WriteRequest, resolvedTarget: ResolvedWrite): TextMutationReceipt { +function applyDirectWrite( + editor: Editor, + request: LegacyWriteRequest, + resolvedTarget: ResolvedWrite, +): TextMutationReceipt { + if (request.kind === 'delete') { + const tr = applyDirectMutationMeta(editor.state.tr.delete(resolvedTarget.range.from, resolvedTarget.range.to)); + editor.dispatch(tr); + return { success: true, resolution: resolvedTarget.resolution }; + } + // Structural-end: create a paragraph at the document end, since raw // insertText cannot place text between block nodes. if (resolvedTarget.structuralEnd) { @@ -85,7 +182,7 @@ function applyDirectWrite(editor: Editor, request: WriteRequest, resolvedTarget: return { success: true, resolution: resolvedTarget.resolution }; } - // text is guaranteed non-empty for insert after validateWriteRequest + // text is guaranteed non-empty for insert/replace after validateWriteRequest const tr = applyDirectMutationMeta( editor.state.tr.insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to), ); @@ -93,7 +190,11 @@ function applyDirectWrite(editor: Editor, request: WriteRequest, resolvedTarget: return { success: true, resolution: resolvedTarget.resolution }; } -function applyTrackedWrite(editor: Editor, request: WriteRequest, resolvedTarget: ResolvedWrite): TextMutationReceipt { +function applyTrackedWrite( + editor: Editor, + request: LegacyWriteRequest, + resolvedTarget: ResolvedWrite, +): TextMutationReceipt { ensureTrackedCapability(editor, { operation: 'write' }); // Structural-end: create a tracked paragraph at the document end. @@ -106,12 +207,13 @@ function applyTrackedWrite(editor: Editor, request: WriteRequest, resolvedTarget // insertTrackedChange is guaranteed to exist after ensureTrackedCapability. const insertTrackedChange = editor.commands!.insertTrackedChange!; + const text = request.kind === 'delete' ? '' : (request.text ?? ''); const changeId = uuidv4(); const didApply = insertTrackedChange({ from: resolvedTarget.range.from, - to: resolvedTarget.range.from, - text: request.text ?? '', + to: request.kind === 'insert' ? resolvedTarget.range.from : resolvedTarget.range.to, + text, id: changeId, }); @@ -155,9 +257,13 @@ function toFailureReceipt(failure: ReceiptFailure, resolvedTarget: ResolvedWrite export function writeAdapter(editor: Editor, request: WriteRequest, options?: MutationOptions): TextMutationReceipt { checkRevision(editor, options?.expectedRevision); + // Keep the internal helper backwards-compatible for direct test callers + // that still exercise legacy replace/delete paths outside the public adapter. + const legacyRequest = request as LegacyWriteRequest; + // Normalize friendly locator fields (blockId + offset) into canonical TextAddress // before resolution. This is the adapter-layer normalization per the contract. - const normalizedRequest = normalizeWriteLocator(request); + const normalizedRequest = normalizeWriteLocator(legacyRequest); const resolvedTarget = resolveWriteTarget(editor, normalizedRequest); if (!resolvedTarget) { From 2988dd81f4b14ce31ac9df9f215be56c5366302f Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 14 Mar 2026 13:35:25 -0700 Subject: [PATCH 7/9] chore: type check --- .../document-api-adapters/write-adapter.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index a03a2939bc..3b58053a08 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -33,6 +33,20 @@ type LegacyDeleteWriteRequest = { type LegacyWriteRequest = WriteRequest | LegacyReplaceWriteRequest | LegacyDeleteWriteRequest; +function resolveLegacyWriteTarget(editor: Editor, request: LegacyWriteRequest): ResolvedWrite | null { + if (request.kind === 'insert') { + return resolveWriteTarget(editor, request); + } + + if (!request.target) return null; + + return resolveWriteTarget(editor, { + kind: 'insert', + target: request.target, + text: '', + }); +} + function validateWriteRequest(request: LegacyWriteRequest, resolvedTarget: ResolvedWrite): ReceiptFailure | null { if (request.kind === 'insert') { if (!request.text) { @@ -201,7 +215,8 @@ function applyTrackedWrite( // insertTrackedChange cannot operate between block nodes, so we use // a direct tr.insert with tracked mutation meta instead. if (resolvedTarget.structuralEnd) { - insertParagraphAtEnd(editor, resolvedTarget.range.from, request.text ?? '', applyTrackedMutationMeta); + const text = request.kind === 'delete' ? '' : (request.text ?? ''); + insertParagraphAtEnd(editor, resolvedTarget.range.from, text, applyTrackedMutationMeta); return { success: true, resolution: resolvedTarget.resolution }; } @@ -265,7 +280,7 @@ export function writeAdapter(editor: Editor, request: WriteRequest, options?: Mu // before resolution. This is the adapter-layer normalization per the contract. const normalizedRequest = normalizeWriteLocator(legacyRequest); - const resolvedTarget = resolveWriteTarget(editor, normalizedRequest); + const resolvedTarget = resolveLegacyWriteTarget(editor, normalizedRequest); if (!resolvedTarget) { throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Mutation target could not be resolved.', { target: normalizedRequest.target, From 1cfb2a07f83bbf9a63a25673b254cc2de628219e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 14 Mar 2026 15:38:01 -0700 Subject: [PATCH 8/9] chore: fix behavior tests --- tests/behavior/helpers/document-api.ts | 24 ++++++++++++++++++- .../programmatic-tracked-change.spec.ts | 17 ++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/tests/behavior/helpers/document-api.ts b/tests/behavior/helpers/document-api.ts index e5a47b910b..a849b0c70e 100644 --- a/tests/behavior/helpers/document-api.ts +++ b/tests/behavior/helpers/document-api.ts @@ -1,6 +1,7 @@ import type { Page } from '@playwright/test'; import type { TextAddress, + SelectionTarget, MatchContext, TrackChangeType, CommentsListResult, @@ -16,7 +17,7 @@ import type { ListsListResult, } from '@superdoc/document-api'; -export type { TextAddress, TextMutationReceipt, TrackChangeType }; +export type { TextAddress, SelectionTarget, TextMutationReceipt, TrackChangeType }; export type ChangeMode = 'direct' | 'tracked'; type MutationOptions = { changeMode?: ChangeMode; dryRun?: boolean; expectedRevision?: number }; type ListMutationName = 'setValue' | 'continuePrevious' | 'setType' | 'separate'; @@ -123,6 +124,10 @@ export async function findTextContexts( if (!address || textRanges.length === 0) return null; return { address, + target: + item?.target?.kind === 'selection' && item?.target?.start && item?.target?.end + ? item.target + : undefined, snippet: typeof item?.snippet === 'string' ? item.snippet : (item?.context?.snippet ?? ''), highlightRange: item?.highlightRange && @@ -175,6 +180,23 @@ export async function findFirstTextRange( return context?.textRanges?.[options.rangeIndex ?? 0] ?? null; } +export async function findFirstSelectionTarget( + page: Page, + pattern: string, + options: { + occurrence?: number; + mode?: 'contains' | 'exact' | 'regex'; + caseSensitive?: boolean; + } = {}, +): Promise { + const contexts = await findTextContexts(page, pattern, { + mode: options.mode, + caseSensitive: options.caseSensitive, + }); + const context = contexts[options.occurrence ?? 0]; + return context?.target ?? null; +} + export async function addComment(page: Page, input: { target: TextAddress; text: string }): Promise { await page.evaluate((payload) => (window as any).editor.doc.comments.create(payload), input); } diff --git a/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts index 4471b75f06..1fd87daa9f 100644 --- a/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts +++ b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts @@ -4,12 +4,13 @@ import { assertDocumentApiReady, deleteText, findFirstTextRange, + findFirstSelectionTarget, getDocumentText, insertText, listTrackChanges, replaceText, } from '../../helpers/document-api.js'; -import type { TextAddress, TextMutationReceipt } from '../../helpers/document-api.js'; +import type { TextAddress, SelectionTarget, TextMutationReceipt } from '../../helpers/document-api.js'; test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); @@ -33,6 +34,13 @@ function requireTextTarget(target: TextAddress | null, pattern: string): TextAdd return target; } +function requireSelectionTarget(target: SelectionTarget | null, pattern: string): SelectionTarget { + if (target == null) { + throw new Error(`Could not find a selection target for pattern "${pattern}".`); + } + return target; +} + function assertMutationSucceeded( operationName: string, receipt: TextMutationReceipt, @@ -50,7 +58,10 @@ test('tracked replace via document-api', async ({ superdoc }) => { await superdoc.type('Here is a tracked style change'); await superdoc.waitForStable(); - const target = requireTextTarget(await findFirstTextRange(superdoc.page, 'a tracked style'), 'a tracked style'); + const target = requireSelectionTarget( + await findFirstSelectionTarget(superdoc.page, 'a tracked style'), + 'a tracked style', + ); const receipt = await replaceText(superdoc.page, { target, text: 'new fancy' }, { changeMode: 'tracked' }); assertMutationSucceeded('replaceText', receipt); @@ -68,7 +79,7 @@ test('tracked delete via document-api', async ({ superdoc }) => { await superdoc.type('Here is some text to delete'); await superdoc.waitForStable(); - const target = requireTextTarget(await findFirstTextRange(superdoc.page, 'Here'), 'Here'); + const target = requireSelectionTarget(await findFirstSelectionTarget(superdoc.page, 'Here'), 'Here'); const receipt = await deleteText(superdoc.page, { target }, { changeMode: 'tracked' }); assertMutationSucceeded('deleteText', receipt); From c4cd25cbaabe722b9d59997285fe48bca2c95391 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 14 Mar 2026 15:54:49 -0700 Subject: [PATCH 9/9] chore: docs tweaks --- apps/docs/document-api/common-workflows.mdx | 48 ++++++++++++++++----- apps/docs/document-api/overview.mdx | 34 ++++++++++++++- apps/docs/document-engine/cli.mdx | 23 +++++----- 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/apps/docs/document-api/common-workflows.mdx b/apps/docs/document-api/common-workflows.mdx index b18ca85078..c9955b87bd 100644 --- a/apps/docs/document-api/common-workflows.mdx +++ b/apps/docs/document-api/common-workflows.mdx @@ -84,7 +84,7 @@ if (preview.valid) { ## Quick search and single edit -For lightweight text edits, use `query.match` and apply against the matched block range: +For lightweight text edits, use `query.match` and apply against the canonical selection target returned by the match: ```ts const match = editor.doc.query.match({ @@ -92,26 +92,50 @@ const match = editor.doc.query.match({ require: 'first', }); -const firstBlock = match.items?.[0]?.blocks?.[0]; -if (firstBlock) { +const target = match.items?.[0]?.target; +if (target) { editor.doc.replace({ - target: { - kind: 'text', - blockId: firstBlock.blockId, - range: { start: firstBlock.range.start, end: firstBlock.range.end }, - }, + target, text: 'bar', }); } ``` +For direct single-operation calls, prefer `item.target`. For plans or multi-step edits, prefer `item.handle.ref` so every step reuses the same resolved match. + +## Build a selection explicitly with ranges.resolve + +Use `ranges.resolve` when you already know the anchor points and want a transparent `SelectionTarget` plus a reusable mutation-ready ref: + +```ts +const resolved = editor.doc.ranges.resolve({ + start: { + kind: 'point', + point: { kind: 'text', blockId: 'p1', offset: 0 }, + }, + end: { + kind: 'point', + point: { kind: 'text', blockId: 'p2', offset: 12 }, + }, +}); + +editor.doc.delete({ target: resolved.target }); + +if (resolved.handle.ref) { + editor.doc.format.apply({ + ref: resolved.handle.ref, + inline: { bold: 'on' }, + }); +} +``` + ## Tracked-mode insert Insert text as a tracked change so reviewers can accept or reject it: ```ts const receipt = editor.doc.insert( - { text: 'new content' }, + { value: 'new content' }, { changeMode: 'tracked' }, ); ``` @@ -124,7 +148,11 @@ Use `capabilities()` to branch on what the editor supports: ```ts const caps = editor.doc.capabilities(); -const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }; +const target = { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 0 }, + end: { kind: 'text', blockId: 'p1', offset: 3 }, +}; if (caps.operations['format.apply'].available) { editor.doc.format.apply({ diff --git a/apps/docs/document-api/overview.mdx b/apps/docs/document-api/overview.mdx index 6e713ae74b..df9cf12481 100644 --- a/apps/docs/document-api/overview.mdx +++ b/apps/docs/document-api/overview.mdx @@ -31,7 +31,39 @@ For mutation targeting and `getNode(...)`, use `NodeAddress`: } ``` -`find(...)` returns SDM/1 `SDAddress` values (for example `kind: "content"`), while `query.match(...)` returns `NodeAddress` values (`kind: "block"` / `kind: "inline"`). Use `query.match(...)` when you need deterministic mutation-ready refs and node addresses. +`find(...)` returns SDM/1 `SDAddress` values (for example `kind: "content"`). For text selectors, `query.match(...)` returns deterministic mutation-ready data: `item.target` as a canonical `SelectionTarget`, `item.handle.ref` as a reusable resolved handle, and `item.address` as the matching `NodeAddress`. + +## Mutation targeting + +Core selection mutations such as `replace`, `delete`, `format.*`, and selection-based mutation plans use `SelectionTarget` or a mutation-ready `ref`. + +```json +{ + "kind": "selection", + "start": { "kind": "text", "blockId": "p1", "offset": 0 }, + "end": { "kind": "text", "blockId": "p1", "offset": 5 } +} +``` + +Use `item.target` from `query.match(...)` for single direct operations: + +```ts +const match = editor.doc.query.match({ + select: { type: 'text', pattern: 'ACME Corp' }, + require: 'first', +}); + +const target = match.items?.[0]?.target; +if (target) { + editor.doc.replace({ target, text: 'NewCo Inc.' }); +} +``` + +Use `item.handle.ref` when you want multiple operations or a mutation plan to reuse the same resolved range. When you need to construct a selection from explicit anchors, call `editor.doc.ranges.resolve(...)` and use its `target` or `handle.ref`. + + +`SelectionTarget` is the canonical target for core selection mutations. Other APIs, such as comments or insertion-point operations, may still use `TextAddress` where the operation is defined around a specific text span or insertion point. + ### Stable IDs across loads diff --git a/apps/docs/document-engine/cli.mdx b/apps/docs/document-engine/cli.mdx index c082728c13..3e4fcc7735 100644 --- a/apps/docs/document-engine/cli.mdx +++ b/apps/docs/document-engine/cli.mdx @@ -35,13 +35,15 @@ superdoc open contract.docx superdoc find --pattern "ACME Corp" # Replace it -superdoc replace --target-json '{"blockId":"...","range":{"start":0,"end":9}}' --text "NewCo Inc." +superdoc replace --target-json '{"kind":"selection","start":{"kind":"text","blockId":"...","offset":0},"end":{"kind":"text","blockId":"...","offset":9}}' --text "NewCo Inc." # Save and close superdoc save superdoc close ``` +For `replace`, `delete`, and `format.*`, prefer canonical `SelectionTarget` JSON or a mutation-ready `ref`. The CLI still accepts legacy single-block text ranges such as `{"kind":"text","blockId":"...","range":{"start":0,"end":9}}` and upgrades them automatically for compatibility, but new scripts should use the canonical selection form. + ## Tracked mode for mutations Use `--tracked` on mutating commands to apply edits as tracked changes instead of direct edits. @@ -49,19 +51,19 @@ Use `--tracked` on mutating commands to apply edits as tracked changes instead o ```bash # Replace text as a tracked change superdoc replace \ - --target-json '{"kind":"text","blockId":"...","range":{"start":0,"end":9}}' \ + --target-json '{"kind":"selection","start":{"kind":"text","blockId":"...","offset":0},"end":{"kind":"text","blockId":"...","offset":9}}' \ --text "NewCo Inc." \ --tracked # Insert text as a tracked change -superdoc insert --text "Added clause" --tracked +superdoc insert --value "Added clause" --tracked ``` `--tracked` is shorthand for `--change-mode tracked`: ```bash superdoc replace \ - --target-json '{"kind":"text","blockId":"...","range":{"start":0,"end":9}}' \ + --target-json '{"kind":"selection","start":{"kind":"text","blockId":"...","offset":0},"end":{"kind":"text","blockId":"...","offset":9}}' \ --text "NewCo Inc." \ --change-mode tracked ``` @@ -97,12 +99,13 @@ The CLI exposes all [Document API operations](/document-api/overview) as command | `superdoc get-node` | `getNode` | Get a node by address | | `superdoc get-node-by-id` | `getNodeById` | Get a node by ID | | `superdoc insert` | `insert` | Insert text at a position | -| `superdoc replace` | `replace` | Replace content at a position | -| `superdoc delete` | `delete` | Delete content at a position | -| `superdoc format bold` | `format.bold` | Toggle bold on a range | -| `superdoc format italic` | `format.italic` | Toggle italic on a range | -| `superdoc format underline` | `format.underline` | Toggle underline on a range | -| `superdoc format strikethrough` | `format.strikethrough` | Toggle strikethrough on a range | +| `superdoc replace` | `replace` | Replace content at a selection | +| `superdoc delete` | `delete` | Delete content at a selection | +| `superdoc format bold` | `format.bold` | Toggle bold on a selection | +| `superdoc format italic` | `format.italic` | Toggle italic on a selection | +| `superdoc format underline` | `format.underline` | Toggle underline on a selection | +| `superdoc format strikethrough` | `format.strikethrough` | Toggle strikethrough on a selection | +| `superdoc ranges resolve` | `ranges.resolve` | Resolve a selection from explicit anchors | | `superdoc create paragraph` | `create.paragraph` | Insert a new paragraph | | `superdoc comments create` | `comments.create` | Create a comment thread | | `superdoc comments list` | `comments.list` | List all comments |