Custom Operations Guide
Operations are the atomic units of document modification. While extensions use operations through transactions, you can also create your own custom operations for domain-specific needs.
What are Operations?
Operations are the lowest-level actions that modify documents. They're executed within transactions and interact directly with DataStore.
Operation vs Extension:
- Operation: Atomic document modification (Model package level)
- Extension: Commands that use operations (Editor level)
When to Create Custom Operations
Create custom operations when:
- You need domain-specific document modifications
- You want reusable operations across multiple extensions
- You need operations that are more efficient than composing existing ones
- You're building a library of operations for others to use
Defining Operations
Operations are defined using defineOperation from @barocss/model/operations.
Basic Operation Definition
import { defineOperation } from '@barocss/model/operations';
import type { TransactionContext } from '@barocss/model';
import type { INode } from '@barocss/datastore';
// 1. Define Operation Type
export interface SetColorOperation {
type: 'setColor';
payload: {
nodeId: string;
color: string;
};
}
// 2. Define Operation Execution Logic
defineOperation('setColor', async (
operation: SetColorOperation,
context: TransactionContext
) => {
const { nodeId, color } = operation.payload;
// Get node
const node = context.dataStore.getNode(nodeId);
if (!node) {
throw new Error(`Node not found: ${nodeId}`);
}
// Store old value for undo
const oldColor = node.attributes?.color;
// Perform modification
context.dataStore.updateNode(nodeId, {
attributes: {
...node.attributes,
color
}
});
// Return result
return {
ok: true,
data: context.dataStore.getNode(nodeId),
inverse: {
type: 'setColor',
payload: { nodeId, color: oldColor }
}
};
});
Operation Structure
interface OperationResult {
ok: boolean;
data?: any;
inverse?: Operation; // For undo support
error?: string;
}
Operation execution:
- Receives operation and TransactionContext
- Accesses DataStore through context
- Modifies document
- Returns result with optional inverse operation
Registering Operations
Operations must be imported to be registered:
// packages/model/src/operations/register-operations.ts
import './setColor';
import './toggleHighlight';
// Import other operations...
Important: Operations must be imported before use:
// At app initialization
import '@barocss/model/operations/register-operations';
// Now operations are available
await transaction(editor, [
{ type: 'setColor', payload: { nodeId: 'p1', color: 'red' } }
]).commit();
Defining Operation DSL
Operation DSL provides helper functions for convenient use in transactions.
DSL Definition
import { defineOperationDSL } from '@barocss/model/operations';
export const setColor = defineOperationDSL(
(nodeId: string, color: string) => ({
type: 'setColor',
payload: { nodeId, color }
}),
{ atom: false, category: 'content' }
);
Using DSL
import { transaction, control, setColor } from '@barocss/model';
const ops = [
...control('p1', [
setColor('p1', 'red')
])
];
await transaction(editor, ops).commit();
DSL benefits:
- Type-safe function calls
- Better developer experience
- Easier to compose operations
- Clearer intent
Complete Example: Highlight Operation
Let's create a complete highlight operation with DSL:
Step 1: Define Operation
// operations/toggleHighlight.ts
import { defineOperation } from '@barocss/model/operations';
import type { TransactionContext } from '@barocss/model';
export interface ToggleHighlightOperation {
type: 'toggleHighlight';
payload: {
nodeId: string;
range: [number, number];
color?: string;
};
}
defineOperation('toggleHighlight', async (
operation: ToggleHighlightOperation,
context: TransactionContext
) => {
const { nodeId, range, color = 'yellow' } = operation.payload;
const node = context.dataStore.getNode(nodeId);
if (!node || typeof node.text !== 'string') {
throw new Error(`Invalid node for toggleHighlight: ${nodeId}`);
}
const marks = node.marks || [];
const [start, end] = range;
// Check if highlight mark exists
const existingMark = marks.find(
m => m.type === 'highlight' &&
m.range[0] === start &&
m.range[1] === end &&
m.attrs?.color === color
);
if (existingMark) {
// Remove mark
const newMarks = marks.filter(m => m !== existingMark);
context.dataStore.updateNode(nodeId, { marks: newMarks });
return {
ok: true,
data: context.dataStore.getNode(nodeId),
inverse: {
type: 'toggleHighlight',
payload: { nodeId, range, color }
}
};
} else {
// Add mark
const newMark = {
type: 'highlight',
range: [start, end],
attrs: { color }
};
context.dataStore.updateNode(nodeId, {
marks: [...marks, newMark]
});
return {
ok: true,
data: context.dataStore.getNode(nodeId),
inverse: {
type: 'toggleHighlight',
payload: { nodeId, range, color }
}
};
}
});
Step 2: Define DSL
// operations-dsl/toggleHighlight.ts
import { defineOperationDSL } from '@barocss/model/operations';
export const toggleHighlight = defineOperationDSL(
(range: [number, number], color?: string) => ({
type: 'toggleHighlight',
payload: { range, color }
}),
{ atom: false, category: 'format' }
);
Step 3: Register Operation
// operations/register-operations.ts
import './toggleHighlight';
// operations-dsl/index.ts
export * from './toggleHighlight';
Step 4: Use in Extension
import { transaction, control, toggleHighlight } from '@barocss/model';
// In extension command
const result = await transaction(editor, [
...control(selection.startNodeId, [
toggleHighlight([startOffset, endOffset], 'yellow')
])
]).commit();
Operation Best Practices
1. Always Validate Inputs
defineOperation('myOperation', async (operation, context) => {
const { nodeId } = operation.payload;
// Validate node exists
const node = context.dataStore.getNode(nodeId);
if (!node) {
throw new Error(`Node not found: ${nodeId}`);
}
// Validate node type
if (node.stype !== 'expected-type') {
throw new Error(`Invalid node type: ${node.stype}`);
}
// Proceed with operation
// ...
});
2. Provide Inverse Operations
return {
ok: true,
data: updatedNode,
inverse: {
type: 'myOperation',
payload: { /* old values */ }
}
};
Inverse operations enable:
- Undo/redo support
- Transaction rollback
- History management
3. Use TransactionContext
defineOperation('myOperation', async (operation, context) => {
// Access DataStore
const node = context.dataStore.getNode(nodeId);
// Access Schema (if needed)
const schema = context.schema;
// Access Editor (if needed)
const editor = context.editor;
// Perform operation
// ...
});
4. Handle Errors Gracefully
defineOperation('myOperation', async (operation, context) => {
try {
// Operation logic
return { ok: true, data: result };
} catch (error) {
return {
ok: false,
error: error.message
};
}
});
5. Keep Operations Atomic
// ✅ Good: Single responsibility
defineOperation('setColor', async (operation, context) => {
// Only sets color
});
// ❌ Bad: Multiple responsibilities
defineOperation('setColorAndSize', async (operation, context) => {
// Sets both color and size - should be two operations
});
Operation Categories
Operations can be categorized for better organization:
defineOperationDSL(
(/* params */) => ({ type: 'myOperation', payload: {} }),
{
atom: false, // Can be split into smaller operations
category: 'content' | 'format' | 'structure' | 'custom'
}
);
Categories:
- content: Text and content modifications
- format: Formatting (marks, styles)
- structure: Node structure changes
- custom: Domain-specific operations
Testing Operations
Test operations in isolation:
import { describe, it, expect } from 'vitest';
import { DataStore } from '@barocss/datastore';
import { TransactionManager } from '@barocss/model';
import './operations/register-operations';
describe('setColor operation', () => {
it('should set node color', async () => {
const dataStore = new DataStore(undefined, schema);
const tm = new TransactionManager(dataStore);
// Create node
dataStore.createNode({
sid: 'p1',
stype: 'paragraph',
content: []
});
// Execute operation
const transaction = tm.createBuilder('test')
.addOperation({
type: 'setColor',
payload: { nodeId: 'p1', color: 'red' }
});
const result = await transaction.commit();
expect(result.success).toBe(true);
// Verify
const node = dataStore.getNode('p1');
expect(node.attributes?.color).toBe('red');
});
});
Related
- Extension Design Guide - How extensions use operations
- Architecture: Model - Model package details
- Core Concepts: Schema & Model - Understanding the model