diff --git a/packages/document-api/src/content-controls/content-controls.test.ts b/packages/document-api/src/content-controls/content-controls.test.ts index 2695795ddd..edeea8ee2a 100644 --- a/packages/document-api/src/content-controls/content-controls.test.ts +++ b/packages/document-api/src/content-controls/content-controls.test.ts @@ -606,6 +606,11 @@ describe('patchRawProperties validates patch op shapes', () => { describe('create.contentControl validation', () => { const createAdapter = { create: mock(noop) } as any; + const validAt = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; it('rejects null input', () => { expect(() => executeCreateContentControl(createAdapter, null as any)).toThrow(/non-null object/); @@ -652,4 +657,43 @@ describe('create.contentControl validation', () => { }); expect(adapter.create).toHaveBeenCalled(); }); + + it('rejects at and target together', () => { + expect(() => + executeCreateContentControl(createAdapter, { + kind: 'inline', + target: validTarget, + at: validAt, + } as any), + ).toThrow(/mutually exclusive/); + }); + + it('rejects invalid at (missing start)', () => { + expect(() => + executeCreateContentControl(createAdapter, { + kind: 'inline', + at: { kind: 'selection', end: { kind: 'text', blockId: 'p1', offset: 5 } }, + } as any), + ).toThrow(/valid SelectionTarget/); + }); + + it('rejects invalid at (wrong kind)', () => { + expect(() => + executeCreateContentControl(createAdapter, { + kind: 'inline', + at: { ...validAt, kind: 'bogus' }, + } as any), + ).toThrow(/valid SelectionTarget/); + }); + + it('accepts valid at (SelectionTarget)', () => { + const adapter = { create: mock(noop) } as any; + executeCreateContentControl(adapter, { + kind: 'inline', + at: validAt, + tag: 'name', + alias: 'Name', + }); + expect(adapter.create).toHaveBeenCalled(); + }); }); diff --git a/packages/document-api/src/content-controls/content-controls.ts b/packages/document-api/src/content-controls/content-controls.ts index e581123224..614aef733e 100644 --- a/packages/document-api/src/content-controls/content-controls.ts +++ b/packages/document-api/src/content-controls/content-controls.ts @@ -9,6 +9,7 @@ import type { MutationOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; import { isRecord, isInteger } from '../validation-primitives.js'; +import { isSelectionTarget } from '../validation/selection-target-validator.js'; import { LOCK_MODES, CONTENT_CONTROL_TYPES, CONTENT_CONTROL_APPEARANCES } from './content-controls.types.js'; import type { NodeKind } from '../types/base.js'; import { NODE_KINDS } from '../types/base.js'; @@ -1033,9 +1034,23 @@ export function executeCreateContentControl( { field: 'lockMode', value: input.lockMode }, ); } + if (input.at !== undefined && input.target !== undefined) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `create.contentControl: "at" and "target" are mutually exclusive — provide one or neither.`, + { field: 'at' }, + ); + } if (input.target !== undefined) { validateCCTarget(input.target, 'create.contentControl'); } + if (input.at !== undefined && !isSelectionTarget(input.at)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `create.contentControl: "at" must be a valid SelectionTarget with kind "selection", start, and end.`, + { field: 'at', value: input.at }, + ); + } if (input.content !== undefined && typeof input.content !== 'string') { throw new DocumentApiValidationError( 'INVALID_INPUT', diff --git a/packages/document-api/src/content-controls/content-controls.types.ts b/packages/document-api/src/content-controls/content-controls.types.ts index 36a8a14d99..be3b54ffc7 100644 --- a/packages/document-api/src/content-controls/content-controls.types.ts +++ b/packages/document-api/src/content-controls/content-controls.types.ts @@ -6,6 +6,7 @@ */ import type { NodeKind } from '../types/base.js'; +import type { SelectionTarget } from '../types/address.js'; import type { ReceiptFailure } from '../types/receipt.js'; // --------------------------------------------------------------------------- @@ -206,6 +207,12 @@ export interface CreateContentControlInput { kind: NodeKind; controlType?: ContentControlType; target?: ContentControlTarget; + /** + * Text range to wrap in the new content control. Mutually exclusive with `target`. + * When `content` is also provided, it replaces the selected text inside the new SDT. + * When `content` is omitted, the existing text in the range becomes the SDT content. + */ + at?: SelectionTarget; tag?: string; alias?: string; lockMode?: LockMode; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 05d0d216b9..a3d1c9c321 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2190,6 +2190,7 @@ function buildContentControlSchemas(): Record