Skip to content

Commit c825717

Browse files
authored
feat(document-api): support wrapping text ranges via contentControls.create (SD-2566) (#2815)
* feat(document-api): support wrapping text ranges via contentControls.create (SD-2566) Add optional `at` field (SelectionTarget) to CreateContentControlInput, enabling callers to wrap arbitrary text ranges in content controls without dropping down to editor internals. Mutually exclusive with the existing `target` field. * fix(document-api): address review findings for contentControls.create at field - Re-acquire editor command after at selection dispatch to avoid stale state bug (CommandService captures state at access time) - Reorder validation so at/target mutual exclusivity check runs before target shape validation for clearer error messages - Document at + content interaction in JSDoc - Extract shared validAt constant in tests * fix(document-api): add at field to create.contentControl contract schema The objectSchema uses additionalProperties: false, so contract-driven callers (CLI, SDK) would reject payloads with the new at field.
1 parent 86b74fe commit c825717

File tree

5 files changed

+88
-5
lines changed

5 files changed

+88
-5
lines changed

packages/document-api/src/content-controls/content-controls.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,11 @@ describe('patchRawProperties validates patch op shapes', () => {
606606

607607
describe('create.contentControl validation', () => {
608608
const createAdapter = { create: mock(noop) } as any;
609+
const validAt = {
610+
kind: 'selection' as const,
611+
start: { kind: 'text' as const, blockId: 'p1', offset: 0 },
612+
end: { kind: 'text' as const, blockId: 'p1', offset: 5 },
613+
};
609614

610615
it('rejects null input', () => {
611616
expect(() => executeCreateContentControl(createAdapter, null as any)).toThrow(/non-null object/);
@@ -652,4 +657,43 @@ describe('create.contentControl validation', () => {
652657
});
653658
expect(adapter.create).toHaveBeenCalled();
654659
});
660+
661+
it('rejects at and target together', () => {
662+
expect(() =>
663+
executeCreateContentControl(createAdapter, {
664+
kind: 'inline',
665+
target: validTarget,
666+
at: validAt,
667+
} as any),
668+
).toThrow(/mutually exclusive/);
669+
});
670+
671+
it('rejects invalid at (missing start)', () => {
672+
expect(() =>
673+
executeCreateContentControl(createAdapter, {
674+
kind: 'inline',
675+
at: { kind: 'selection', end: { kind: 'text', blockId: 'p1', offset: 5 } },
676+
} as any),
677+
).toThrow(/valid SelectionTarget/);
678+
});
679+
680+
it('rejects invalid at (wrong kind)', () => {
681+
expect(() =>
682+
executeCreateContentControl(createAdapter, {
683+
kind: 'inline',
684+
at: { ...validAt, kind: 'bogus' },
685+
} as any),
686+
).toThrow(/valid SelectionTarget/);
687+
});
688+
689+
it('accepts valid at (SelectionTarget)', () => {
690+
const adapter = { create: mock(noop) } as any;
691+
executeCreateContentControl(adapter, {
692+
kind: 'inline',
693+
at: validAt,
694+
tag: 'name',
695+
alias: 'Name',
696+
});
697+
expect(adapter.create).toHaveBeenCalled();
698+
});
655699
});

packages/document-api/src/content-controls/content-controls.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import type { MutationOptions } from '../write/write.js';
1010
import { DocumentApiValidationError } from '../errors.js';
1111
import { isRecord, isInteger } from '../validation-primitives.js';
12+
import { isSelectionTarget } from '../validation/selection-target-validator.js';
1213
import { LOCK_MODES, CONTENT_CONTROL_TYPES, CONTENT_CONTROL_APPEARANCES } from './content-controls.types.js';
1314
import type { NodeKind } from '../types/base.js';
1415
import { NODE_KINDS } from '../types/base.js';
@@ -1033,9 +1034,23 @@ export function executeCreateContentControl(
10331034
{ field: 'lockMode', value: input.lockMode },
10341035
);
10351036
}
1037+
if (input.at !== undefined && input.target !== undefined) {
1038+
throw new DocumentApiValidationError(
1039+
'INVALID_INPUT',
1040+
`create.contentControl: "at" and "target" are mutually exclusive — provide one or neither.`,
1041+
{ field: 'at' },
1042+
);
1043+
}
10361044
if (input.target !== undefined) {
10371045
validateCCTarget(input.target, 'create.contentControl');
10381046
}
1047+
if (input.at !== undefined && !isSelectionTarget(input.at)) {
1048+
throw new DocumentApiValidationError(
1049+
'INVALID_INPUT',
1050+
`create.contentControl: "at" must be a valid SelectionTarget with kind "selection", start, and end.`,
1051+
{ field: 'at', value: input.at },
1052+
);
1053+
}
10391054
if (input.content !== undefined && typeof input.content !== 'string') {
10401055
throw new DocumentApiValidationError(
10411056
'INVALID_INPUT',

packages/document-api/src/content-controls/content-controls.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { NodeKind } from '../types/base.js';
9+
import type { SelectionTarget } from '../types/address.js';
910
import type { ReceiptFailure } from '../types/receipt.js';
1011

1112
// ---------------------------------------------------------------------------
@@ -206,6 +207,12 @@ export interface CreateContentControlInput {
206207
kind: NodeKind;
207208
controlType?: ContentControlType;
208209
target?: ContentControlTarget;
210+
/**
211+
* Text range to wrap in the new content control. Mutually exclusive with `target`.
212+
* When `content` is also provided, it replaces the selected text inside the new SDT.
213+
* When `content` is omitted, the existing text in the range becomes the SDT content.
214+
*/
215+
at?: SelectionTarget;
209216
tag?: string;
210217
alias?: string;
211218
lockMode?: LockMode;

packages/document-api/src/contract/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,6 +2190,7 @@ function buildContentControlSchemas(): Record<ContentControlOperationId, Operati
21902190
kind: { enum: ['block', 'inline'] },
21912191
controlType: { type: 'string' },
21922192
target: contentControlTargetSchema,
2193+
at: selectionTargetSchema,
21932194
tag: { type: 'string' },
21942195
alias: { type: 'string' },
21952196
lockMode: { enum: ['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'] },

packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414

1515
import { Fragment, type Node as ProseMirrorNode, type Schema } from 'prosemirror-model';
16+
import { TextSelection } from 'prosemirror-state';
1617
import type { Editor } from '../../core/Editor.js';
1718
import type { ProseMirrorJSON } from '../../core/types/EditorTypes.js';
1819
import type {
@@ -89,6 +90,7 @@ import type {
8990
import { DocumentApiAdapterError } from '../errors.js';
9091
import { executeDomainCommand } from './plan-wrappers.js';
9192
import { clearIndexCache } from '../helpers/index-cache.js';
93+
import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js';
9294

9395
// Shared helpers — single source of truth for SDT logic
9496
import {
@@ -1853,30 +1855,44 @@ function createWrapper(
18531855
return true;
18541856
}
18551857

1858+
// When a SelectionTarget is provided, set the editor selection to that
1859+
// range so insertStructuredContentInline/Block wraps the targeted text.
1860+
if (input.at) {
1861+
const { absFrom, absTo } = resolveSelectionTarget(editor, input.at);
1862+
const { tr } = editor.state;
1863+
tr.setSelection(TextSelection.create(tr.doc, absFrom, absTo));
1864+
dispatchTransaction(editor, tr);
1865+
}
1866+
1867+
// Re-acquire the command so it picks up fresh editor state — important
1868+
// when `at` dispatched a selection change above.
1869+
const cmd = editor.commands?.[commandName];
1870+
if (typeof cmd !== 'function') return false;
1871+
18561872
// Default: delegate to the editor command (inserts at current selection).
18571873
if (contentText !== undefined) {
18581874
if (input.kind === 'block') {
18591875
if (isCheckboxCreate) {
18601876
return Boolean(
1861-
insertCmd({
1877+
cmd({
18621878
attrs,
18631879
json: { type: 'paragraph', content: [buildCheckboxTextJson(checkboxSymbol)] },
18641880
}),
18651881
);
18661882
}
18671883
return Boolean(
1868-
insertCmd({
1884+
cmd({
18691885
attrs,
18701886
json: { type: 'paragraph', content: [{ type: 'text', text: contentText }] },
18711887
}),
18721888
);
18731889
}
18741890
if (isCheckboxCreate) {
1875-
return Boolean(insertCmd({ attrs, json: buildCheckboxTextJson(checkboxSymbol) }));
1891+
return Boolean(cmd({ attrs, json: buildCheckboxTextJson(checkboxSymbol) }));
18761892
}
1877-
return Boolean(insertCmd({ attrs, text: contentText }));
1893+
return Boolean(cmd({ attrs, text: contentText }));
18781894
}
1879-
return Boolean(insertCmd({ attrs }));
1895+
return Boolean(cmd({ attrs }));
18801896
});
18811897
}
18821898

0 commit comments

Comments
 (0)