Model Operations API
Model operations are business logic implementations that use DataStore operations. They handle selection mapping, inverse operations for undo/redo, and transaction context.
Overview
Model operations are defined using defineOperation() and are executed within transactions. They bridge the gap between DSL helpers and DataStore operations.
Operation Structure
defineOperation('operationName', async (operation, context) => {
// 1. Extract payload
const { nodeId, ... } = operation.payload;
// 2. Call DataStore operations
context.dataStore.range.insertText(...);
// 3. Map selection (if needed)
if (context.selection?.current) {
// Update selection offsets
}
// 4. Return result with inverse
return {
ok: true,
data: result,
inverse: { type: 'inverseOp', payload: {...} }
};
});
Transaction Context
All model operations receive a TransactionContext:
interface TransactionContext {
dataStore: DataStore; // DataStore instance
selection: {
before: ModelSelection; // Selection before transaction
current: ModelSelection; // Current selection (mutable)
};
schema: Schema; // Active schema
}
Text Operations
insertText
Inserts text at a position in a text node.
DSL: insertText(pos, text) or insertText(nodeId, pos, text)
Payload:
{
nodeId: string;
pos: number;
text: string;
}
Behavior:
- Calls
DataStore.range.insertText() - Maps selection: moves offsets after insertion point forward
- Returns inserted text
- Inverse:
deleteTextRange
Example:
// DSL
const ops = control('text-1', [
insertText(5, 'Hello')
]);
// Executes:
// 1. DataStore.range.insertText({ startNodeId: 'text-1', startOffset: 5, ... }, 'Hello')
// 2. Updates selection offsets if selection is after position 5
// 3. Returns { ok: true, data: 'Hello', inverse: {...} }
deleteTextRange
Deletes text in a range.
DSL: deleteTextRange(startPosition, endPosition) or deleteTextRange(nodeId, startPosition, endPosition)
Payload:
{
nodeId: string;
startPosition: number;
endPosition: number;
}
Behavior:
- Calls
DataStore.range.deleteText() - Maps selection: adjusts offsets
- Returns deleted text
- Inverse:
insertText
replaceText
Replaces text in a range.
DSL: replaceText(newText) or replaceText(nodeId, newText)
Payload:
{
nodeId: string;
newText: string;
startPosition?: number;
endPosition?: number;
}
Behavior:
- Calls
DataStore.range.replaceText() - Maps selection
- Returns replaced text
- Inverse:
replaceTextwith old text
Node Operations
create
Creates a node with children.
DSL: create(stype, attributes?, content?)
Payload:
{
node: INode; // Node with nested children (objects)
options?: any;
}
Behavior:
- Calls
DataStore.createNodeWithChildren() - Sets root node if first node
- Does not change selection
- Inverse:
delete
Example:
// DSL
const ops = [
create('paragraph', {}, [
{ stype: 'inline-text', text: 'Hello' }
])
];
// Executes:
// 1. DataStore.createNodeWithChildren(node, schema)
// 2. Sets root if first node
// 3. Returns { ok: true, data: createdNode, inverse: { type: 'delete', ... } }
delete
Deletes a node and its descendants.
DSL: delete() or delete(nodeId)
Payload:
{
nodeId: string;
}
Behavior:
- Recursively deletes all descendants
- Removes from parent's content array
- Cannot delete root node (throws error)
- Maps selection: clears if selection was in deleted node
- Inverse:
create
Example:
// DSL
const ops = control('node-1', [
delete()
]);
// Executes:
// 1. Gets all descendants
// 2. Deletes descendants in reverse order
// 3. Removes from parent
// 4. Deletes node
// 5. Updates selection if needed
update
Updates a node with partial changes.
DSL: update(updates)
Payload:
{
nodeId: string;
data: Partial<INode>; // Fields to update
}
Behavior:
- Calls
DataStore.updateNode() - Merges attributes (shallow-merge)
- Validates against schema
- Does not change selection
- Inverse:
updatewith old data
Example:
// DSL
const ops = control('text-1', [
update({ text: 'Updated' })
]);
// Executes:
// 1. DataStore.updateNode('text-1', { text: 'Updated' })
// 2. Returns { ok: true, data: updatedNode, inverse: {...} }
transformNode
Transforms a node to a different type.
DSL: transformNode(newType, newAttrs?)
Payload:
{
nodeId: string;
newType: string;
newAttrs?: Record<string, any>;
}
Behavior:
- Calls
DataStore.transformNode() - Validates new type against schema
- Preserves content and relationships
- Inverse:
transformNodewith old type
Content Operations
addChild
Adds a child to a parent's content array.
DSL: addChild(child, position?)
Payload:
{
nodeId: string; // Parent node ID
child: INode | string;
position?: number;
}
Behavior:
- Calls
DataStore.content.addChild() - Creates child if object provided
- Updates parent's content array
- Updates child's parentId
- Inverse:
removeChild
removeChild
Removes a child from parent's content array.
DSL: removeChild(childId)
Payload:
{
nodeId: string; // Parent node ID
childId: string;
}
Behavior:
- Calls
DataStore.content.removeChild() - Removes from parent's content array
- Clears child's parentId
- Inverse:
addChild
moveNode
Moves a node to a new parent.
DSL: moveNode(newParentId, position?)
Payload:
{
nodeId: string;
newParentId: string;
position?: number;
}
Behavior:
- Calls
DataStore.content.moveNode() - Removes from old parent
- Adds to new parent
- Updates parentId
- Inverse:
moveNodewith old parent
reorderChildren
Reorders children in a parent's content array.
DSL: reorderChildren(childIds)
Payload:
{
nodeId: string; // Parent node ID
childIds: string[];
}
Behavior:
- Calls
DataStore.content.reorderChildren() - Updates parent's content array
- Inverse:
reorderChildrenwith old order
Mark Operations
applyMark
Applies a mark to a range.
DSL: applyMark(markType, range, attrs?)
Payload:
{
nodeId: string;
markType: string;
range: [number, number];
attrs?: Record<string, any>;
}
Behavior:
- Calls
DataStore.mark.setMarks()orDataStore.range.applyMark() - Adds mark to node
- Merges with existing marks
- Inverse:
removeMark
removeMark
Removes a mark from a range.
DSL: removeMark(markType, range?)
Payload:
{
nodeId: string;
markType: string;
range?: [number, number];
}
Behavior:
- Calls
DataStore.mark.removeMark() - Removes mark from node
- Updates marks array
- Inverse:
applyMark
toggleMark
Toggles a mark on/off.
DSL: toggleMark(markType, range, attrs?)
Payload:
{
nodeId: string;
markType: string;
range: [number, number];
attrs?: Record<string, any>;
}
Behavior:
- Calls
DataStore.mark.toggleMark() - Adds if not present, removes if present
- Inverse:
toggleMark(self-inverse)
updateMark
Updates mark attributes.
DSL: updateMark(markType, attrs, range?)
Payload:
{
nodeId: string;
markType: string;
attrs: Record<string, any>;
range?: [number, number];
}
Behavior:
- Calls
DataStore.mark.updateMark() - Updates mark attributes
- Inverse:
updateMarkwith old attrs
Split/Merge Operations
splitTextNode
Splits a text node at a position.
DSL: splitTextNode(splitPosition)
Payload:
{
nodeId: string;
splitPosition: number;
}
Behavior:
- Calls
DataStore.splitMerge.splitTextNode() - Creates new node with text after split
- Updates original node with text before split
- Maps selection if needed
- Inverse:
mergeTextNodes
mergeTextNodes
Merges two adjacent text nodes.
DSL: mergeTextNodes(rightNodeId)
Payload:
{
nodeId: string; // Left node ID
rightNodeId: string;
}
Behavior:
- Calls
DataStore.splitMerge.mergeTextNodes() - Concatenates text
- Merges marks
- Removes right node
- Maps selection if needed
- Inverse:
splitTextNode
splitBlockNode
Splits a block node at a position.
DSL: splitBlockNode(splitPosition)
Payload:
{
nodeId: string;
splitPosition: number;
}
Behavior:
- Calls
DataStore.splitMerge.splitBlockNode() - Creates new block with children after split
- Updates original block
- Inverse:
mergeBlockNodes
mergeBlockNodes
Merges two adjacent block nodes.
DSL: mergeBlockNodes(rightNodeId)
Payload:
{
nodeId: string; // Left node ID
rightNodeId: string;
}
Behavior:
- Calls
DataStore.splitMerge.mergeBlockNodes() - Merges children
- Removes right node
- Inverse:
splitBlockNode
Selection Operations
selectRange
Sets selection to a range.
DSL: selectRange(startNodeId, startOffset, endNodeId, endOffset)
Payload:
{
startNodeId: string;
startOffset: number;
endNodeId: string;
endOffset: number;
}
Behavior:
- Updates
context.selection.current - Does not call DataStore (selection is managed by Editor Core)
- Inverse:
selectRangewith old selection
selectNode
Sets selection to a node.
DSL: selectNode(nodeId)
Payload:
{
nodeId: string;
}
Behavior:
- Sets selection to cover entire node
- Inverse:
selectRangewith old selection
clearSelection
Clears selection.
DSL: clearSelection()
Payload: None
Behavior:
- Clears
context.selection.current - Inverse:
selectRangewith old selection
Transform Operations
wrap
Wraps a range with a new node.
DSL: wrap(wrapperType, wrapperAttrs?)
Payload:
{
nodeId: string; // Start node ID
endNodeId: string;
wrapperType: string;
wrapperAttrs?: Record<string, any>;
}
Behavior:
- Creates wrapper node
- Moves selected nodes into wrapper
- Updates parent relationships
- Inverse:
unwrap
unwrap
Removes wrapper node, promoting children.
DSL: unwrap()
Payload:
{
nodeId: string; // Wrapper node ID
}
Behavior:
- Moves children to wrapper's parent
- Deletes wrapper node
- Inverse:
wrap
indentNode / outdentNode
Indents or outdents a block node.
DSL: indentNode() / outdentNode()
Payload:
{
nodeId: string;
}
Behavior:
- Moves node to previous/next block's content
- Updates parent relationships
- Inverse:
outdentNode/indentNode
indentText / outdentText
Indents or outdents text (adds/removes prefix).
DSL: indentText(prefix?) / outdentText(prefix?)
Payload:
{
nodeId: string;
prefix?: string;
}
Behavior:
- Adds/removes prefix from text
- Updates text content
- Inverse:
outdentText/indentText
Clipboard Operations
copy
Copies selected content.
DSL: copy()
Payload: None (uses current selection)
Behavior:
- Extracts text/content from selection
- Stores in clipboard
- Does not modify document
- No inverse
cut
Cuts selected content.
DSL: cut()
Payload: None (uses current selection)
Behavior:
- Copies content
- Deletes selected content
- Inverse:
paste
paste
Pastes content at selection.
DSL: paste(content?)
Payload:
{
content?: INode[]; // Optional content to paste
}
Behavior:
- Inserts content at selection
- Creates nodes from clipboard
- Maps selection after paste
- Inverse:
delete(if content provided)
Utility Operations
autoMergeTextNodes
Automatically merges adjacent text nodes.
DSL: autoMergeTextNodes()
Payload:
{
nodeId: string;
}
Behavior:
- Finds adjacent text nodes
- Merges if same type and no marks
- Inverse:
splitTextNode
cloneNodeWithChildren
Clones a node with all its children.
DSL: cloneNodeWithChildren(newParentId?)
Payload:
{
nodeId: string;
newParentId?: string;
}
Behavior:
- Recursively clones node and children
- Assigns new IDs
- Optionally adds to new parent
- Inverse:
delete(for cloned nodes)
Operation Execution Flow
Return Value Structure
All model operations return:
{
ok: boolean; // Success/failure
data?: any; // Operation result data
inverse?: TransactionOperation; // Inverse operation for undo
error?: string; // Error message (if ok: false)
}
Selection Mapping
Model operations automatically map selection when content changes:
- Text insertion: Moves offsets after insertion point forward
- Text deletion: Moves offsets after deletion point backward
- Node deletion: Clears selection if in deleted node
- Node creation: Does not change selection
Inverse Operations
All operations provide inverse operations for undo/redo:
insertText→deleteTextRangedeleteTextRange→insertTextcreate→deletedelete→createupdate→update(with old data)toggleMark→toggleMark(self-inverse)
Related
- Operations Overview - Understanding the operation hierarchy
- DataStore Operations API - DataStore layer operations
- Model Operation DSL API - DSL helpers
- Operation Selection Guide - How to choose operations
- Custom Operations Guide - Creating custom operations