@rtif-sdk/engine
v3.3.0
Published
RTIF editor engine: dispatch, commands, history, features.
Readme
@rtif-sdk/engine
The stateful RTIF editor engine: one synchronous dispatch path, namespaced
feature commands, a single inverse-based history, and pending marks. Depends on
@rtif-sdk/core only — no DOM.
npm install @rtif-sdk/engine @rtif-sdk/core30 seconds
import { createEngine, insertTextCommand, toggleMarkCommand } from '@rtif-sdk/engine';
import type { Feature } from '@rtif-sdk/engine';
const bold: Feature = {
name: 'bold',
marks: { bold: {} },
commands: { toggle: toggleMarkCommand('bold') },
};
const text: Feature = { name: 'text', commands: { insert: insertTextCommand() } };
const engine = createEngine({ features: [bold, text] });
engine.dispatch([{ type: 'insert_text', at: 0, text: 'Hello' }]);
engine.setSelection({ anchor: 0, focus: 5 });
engine.exec('bold.toggle'); // commands are `${feature.name}.${key}`
engine.isActive('bold.toggle'); // true
engine.undo();createEngine({ doc?, features?, historyLimit? }) — the default document is one
empty paragraph; historyLimit defaults to 1000 entries.
dispatch and transactions
dispatch(ops, opts?) is synchronous and atomic: validate → for each op
invert then apply → normalize touched blocks → map the selection → run
onTransaction hooks → push history → notify onState subscribers once.
DispatchOptions: origin ('user' default, 'history', 'remote', or
custom), historyGroup (coalescing key), selectionAfter (explicit
post-transaction selection; otherwise the selection is mapped through each op).
Subscribers get the committed Transaction: ops, inverses (in apply-ready
order — the reverse of ops), stateBefore/stateAfter, origin,
historyGroup. setSelection notifies with tx = null and no history entry.
Commands
interface Command {
run(ctx: CommandContext, params?: unknown): void;
canRun?(ctx: CommandQueryContext): boolean; // default true
isActive?(ctx: CommandQueryContext): boolean; // default false
}
// The ONE object query hooks receive — destructure what you need
// (`({ state, history }) => …`); the signatures never grow positional params.
interface CommandQueryContext {
readonly state: EngineState;
readonly pendingMarks: Marks | null;
readonly schema: Schema;
readonly history: HistoryCapability; // canUndo() / canRedo()
}engine.exec(name, params?) runs a command; canExec(name) and
isActive(name) drive toolbars. Shipped factories:
| Factory | Does |
|---|---|
| toggleMarkCommand(mark, value?) | Range: add/remove everywhere. Collapsed: pending override. |
| setMarkCommand(mark) | exec('link.set', { href }); null params removes. |
| setBlockTypeCommand(type, attrs?) | Sets every block intersecting the selection. |
| toggleBlockTypeCommand(type, attrs?) | Same, but reverts to paragraph when already active. |
| insertTextCommand() | exec(name, { text }); carries typing marks; coalesces as 'typing'. |
| deleteSelectionCommand() | One atomic replace_range. |
| deleteDirectionCommand('backward' \| 'forward') | Code-point delete, boundary merges, atomic neighbors. |
| splitBlockCommand(generateId?) | Enter: replaces selection + splits, in one transaction. |
| undoCommand() / redoCommand() | Call ctx.undo() / ctx.redo(). |
Undo/redo and history groups
One history mechanism: a stack of { ops, inverses, selectionBefore,
selectionAfter, group }. Adjacent entries with the same non-undefined
historyGroup coalesce — insertTextCommand dispatches collapsed-cursor
inserts with { historyGroup: 'typing' }, so a typed word undoes as one step.
undo()/redo() replay with origin: 'history' (no new history entry) and
are safe no-ops on empty stacks.
pendingMarks (bold-then-type)
Toggle bold at a collapsed cursor and no operation is dispatched — there is no text to format yet. Instead the engine records a pending override:
engine.exec('bold.toggle'); // pendingMarks → { bold: true }
engine.exec('text.insert', { text: 'hi' }); // 'hi' is inserted bold; pending consumedpendingMarks is Marks | null; the value false means "explicitly off"
(toggling bold off inside bold text). Clearing is an engine policy, not a UI
subscriber's job: any document-changing transaction consumes them, any
collapsed-cursor move clears them, and marks whose MarkSpec is exclusive
(links) also clear at their trailing edge — for every input path, always.
isActive sees pendingMarks on its query context, so toolbars reflect the
override.
Features and onTransaction
interface Feature {
name: string;
marks?: Record<string, MarkSpec>;
blocks?: Record<string, BlockSpec>;
commands?: Record<string, Command>; // registered as `${name}.${key}`
keymap?: Record<string, KeyBinding>; // used by web; validated here
onTransaction?(tx: Transaction, state: EngineState): readonly Operation[] | void;
}
// 'Mod-b' → 'bold.toggle', or a command plus bound params:
// 'Mod-Alt-2' → { command: 'heading.toggle', params: { level: 2 } }
type KeyBinding = string | { command: string; params?: unknown };onTransaction hooks run in feature order after each transaction's ops apply.
Returned ops join the same transaction (applied, inverted, re-offered to all
hooks — bounded to 10 passes, then EngineError('HOOK_LIMIT')), so input rules
and renumbering undo together with the edit that triggered them. Hooks run for
every origin, including 'history' replays — check tx.origin to avoid
fighting undo. A hook that throws fails the whole dispatch: the transaction
is discarded and the error rethrown — no silent skip-and-continue corruption.
The reentrancy rule
Calling dispatch (or exec, undo, setSelection) from inside an onState
subscriber or onTransaction hook throws EngineError('REENTRANT_DISPATCH').
There is no deferred queue and no reconciliation flag — the contract is
explicit. Need follow-up work from a subscriber? Defer it yourself:
engine.onState((state) => {
if (needsFixup(state)) queueMicrotask(() => engine.dispatch(fixupOps));
});Engine errors are EngineError with code: 'REENTRANT_DISPATCH' |
'UNKNOWN_COMMAND' | 'INVALID_FEATURE' | 'HOOK_LIMIT'. Document/operation errors
are RtifError from @rtif-sdk/core. mergeSchemas(features) (also used by web)
merges feature specs into one Schema and throws on conflicts.
