feat(writekit): rename @robonen/editor to @robonen/writekit

Rename the rich-text editor package and all Editor* exports to Writekit*;
remove the old vue/editor tree.
This commit is contained in:
2026-06-15 16:54:06 +07:00
parent 55e78786d6
commit 263c32002f
149 changed files with 1563 additions and 1748 deletions
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { createDoc, createNode, nodeText } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { applyStep } from '../step';
const schema = createDefaultRegistry().schema;
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
}
describe('applyStep', () => {
it('inserts and inverts to the original', () => {
const doc = createDoc([para('a', 'hi')]);
const inserted = applyStep(doc, { type: 'insertInline', blockId: 'a', offset: 2, content: [{ text: '!', marks: [] }] }, schema);
expect(nodeText(inserted.doc.content[0]!)).toBe('hi!');
const back = applyStep(inserted.doc, inserted.inverted, schema);
expect(nodeText(back.doc.content[0]!)).toBe('hi');
});
it('splits a block, and its inverse merges back', () => {
const doc = createDoc([para('a', 'hello')]);
const split = applyStep(doc, { type: 'splitBlock', blockId: 'a', offset: 2, newId: 'b' }, schema);
expect(split.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
const merged = applyStep(split.doc, split.inverted, schema);
expect(merged.doc.content.map(block => nodeText(block))).toEqual(['hello']);
});
it('moves a block, and its inverse restores order', () => {
const doc = createDoc([para('a', '1'), para('b', '2'), para('c', '3')]);
const moved = applyStep(doc, { type: 'moveBlock', blockId: 'a', toIndex: 2 }, schema);
expect(moved.doc.content.map(block => block.id)).toEqual(['b', 'c', 'a']);
const back = applyStep(moved.doc, moved.inverted, schema);
expect(back.doc.content.map(block => block.id)).toEqual(['a', 'b', 'c']);
});
it('adds a mark and inverts to the prior inline state', () => {
const doc = createDoc([para('a', 'abc')]);
const marked = applyStep(doc, { type: 'addMark', blockId: 'a', from: 0, to: 3, mark: { type: 'bold' } }, schema);
expect(marked.doc.content[0]!.content).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
const back = applyStep(marked.doc, marked.inverted, schema);
expect(back.doc.content[0]!.content).toEqual([{ text: 'abc', marks: [] }]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { WritekitState } from './writekit-state';
import type { Transaction } from './transaction';
/** Applies a transaction, updating writekit state and notifying subscribers. */
export type Dispatch = (tr: Transaction) => void;
/**
* Minimal view surface a command may use to move real DOM focus across blocks.
* The Vue `WritekitContext` is structurally compatible; pure logic/tests can pass
* a stub. Keeps the command layer free of any Vue/DOM dependency.
*/
export interface CommandView {
focusBlock: (blockId: string, offset: number | 'start' | 'end') => void;
}
/**
* A command in the ProseMirror style: returns `true` when applicable (and
* dispatches when `dispatch` is provided), `false` otherwise so the keymap can
* fall through to native behavior. Called without `dispatch` it is a dry run for
* computing UI enabled/active state.
*/
export type Command = (state: WritekitState, dispatch?: Dispatch, view?: CommandView) => boolean;
/** A parameterized command constructor. */
export type CommandFactory<Args extends readonly unknown[] = readonly unknown[]> = (...args: Args) => Command;
+69
View File
@@ -0,0 +1,69 @@
import type { Selection } from '../model';
import type { Step } from './step';
/**
* One undoable change: the steps it applied, their inverses, and the selection
* before and after. Undo replays `inverted` (reversed); redo replays `steps`.
*/
export interface HistoryEntry {
readonly steps: readonly Step[];
readonly inverted: readonly Step[];
readonly selectionBefore: Selection;
readonly selectionAfter: Selection;
}
export interface HistoryOptions {
/** Maximum number of undo entries to retain (default 200). */
readonly maxSize?: number;
}
/**
* Undo/redo stacks of inverse-step entries. Borrows the ergonomics of stdlib's
* command history (bounded size, redo cleared on a new edit) but stores data
* (inverse steps) rather than closures — which is what makes it serializable and
* collab-friendly.
*/
export interface History {
/** Record a new edit, clearing the redo stack. */
record: (entry: HistoryEntry) => void;
/** Pop the latest undo entry (and push it onto the redo stack). */
undo: () => HistoryEntry | undefined;
/** Pop the latest redo entry (and push it back onto the undo stack). */
redo: () => HistoryEntry | undefined;
canUndo: () => boolean;
canRedo: () => boolean;
clear: () => void;
}
export function createHistory(options: HistoryOptions = {}): History {
const maxSize = options.maxSize ?? 200;
const undoStack: HistoryEntry[] = [];
const redoStack: HistoryEntry[] = [];
return {
record(entry) {
undoStack.push(entry);
if (undoStack.length > maxSize)
undoStack.shift();
redoStack.length = 0;
},
undo() {
const entry = undoStack.pop();
if (entry)
redoStack.push(entry);
return entry;
},
redo() {
const entry = redoStack.pop();
if (entry)
undoStack.push(entry);
return entry;
},
canUndo: () => undoStack.length > 0,
canRedo: () => redoStack.length > 0,
clear() {
undoStack.length = 0;
redoStack.length = 0;
},
};
}
+6
View File
@@ -0,0 +1,6 @@
export * from './command';
export * from './writekit-state';
export * from './step';
export * from './transaction';
export * from './history';
export * from './writekit';
+223
View File
@@ -0,0 +1,223 @@
import { clamp, move } from '@robonen/stdlib';
import type { Attrs, Inline, Mark, Node, WritekitDocument } from '../model';
import {
addMarkInline,
blockById,
blockIndex,
createNode,
deleteTextInline,
findBlock,
inlineLength,
insertInline,
nodeInline,
normalizeInline,
removeMarkInline,
replaceBlocks,
replaceInline,
sliceInline,
withAttrs,
withContent,
withType,
} from '../model';
import type { Schema } from '../schema';
import { marksAllowed } from '../schema';
/**
* The atomic, invertible, serializable unit of change. Steps are the contract
* shared by the undo history (each carries its exact inverse) and the CRDT
* adapter (each maps to a CRDT operation). Keeping the set small (~12) means a
* new block type never needs a new step.
*/
export type Step
= | { readonly type: 'insertInline'; readonly blockId: string; readonly offset: number; readonly content: Inline }
| { readonly type: 'deleteText'; readonly blockId: string; readonly from: number; readonly to: number }
| { readonly type: 'replaceInline'; readonly blockId: string; readonly from: number; readonly to: number; readonly content: Inline }
| { readonly type: 'addMark'; readonly blockId: string; readonly from: number; readonly to: number; readonly mark: Mark }
| { readonly type: 'removeMark'; readonly blockId: string; readonly from: number; readonly to: number; readonly mark: Mark }
| { readonly type: 'setAttrs'; readonly blockId: string; readonly attrs: Attrs }
| { readonly type: 'setType'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs }
| { readonly type: 'splitBlock'; readonly blockId: string; readonly offset: number; readonly newId: string; readonly newType?: string; readonly newAttrs?: Attrs }
| { readonly type: 'mergeBlock'; readonly blockId: string; readonly intoId: string }
| { readonly type: 'insertBlock'; readonly node: Node; readonly index: number }
| { readonly type: 'removeBlock'; readonly blockId: string }
| { readonly type: 'moveBlock'; readonly blockId: string; readonly toIndex: number }
| { readonly type: 'setDoc'; readonly doc: WritekitDocument };
export interface StepResult {
readonly doc: WritekitDocument;
readonly inverted: Step;
}
function mapBlock(doc: WritekitDocument, blockId: string, fn: (node: Node) => Node): WritekitDocument {
return replaceBlocks(doc, doc.content.map(block => (block.id === blockId ? fn(block) : block)));
}
/**
* Apply a single step to a document, returning the next document and the exact
* inverse step (so undo is correct by construction). Pure: never mutates input.
* If the addressed block is missing the step is a no-op (defends against remote
* steps referencing concurrently-removed blocks).
*/
export function applyStep(doc: WritekitDocument, step: Step, schema: Schema): StepResult {
switch (step.type) {
case 'insertInline': {
const block = blockById(doc, step.blockId);
if (!block)
return { doc, inverted: step };
const next = normalizeInline(insertInline(nodeInline(block), step.offset, step.content));
return {
doc: mapBlock(doc, step.blockId, b => withContent(b, next)),
inverted: { type: 'deleteText', blockId: step.blockId, from: step.offset, to: step.offset + inlineLength(step.content) },
};
}
case 'deleteText': {
const block = blockById(doc, step.blockId);
if (!block)
return { doc, inverted: step };
const inline = nodeInline(block);
const removed = sliceInline(inline, step.from, step.to);
return {
doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(deleteTextInline(inline, step.from, step.to)))),
inverted: { type: 'insertInline', blockId: step.blockId, offset: step.from, content: removed },
};
}
case 'replaceInline': {
const block = blockById(doc, step.blockId);
if (!block)
return { doc, inverted: step };
const inline = nodeInline(block);
const removed = sliceInline(inline, step.from, step.to);
return {
doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(replaceInline(inline, step.from, step.to, step.content)))),
inverted: { type: 'replaceInline', blockId: step.blockId, from: step.from, to: step.from + inlineLength(step.content), content: removed },
};
}
case 'addMark':
case 'removeMark': {
const block = blockById(doc, step.blockId);
if (!block)
return { doc, inverted: step };
const inline = nodeInline(block);
const removed = sliceInline(inline, step.from, step.to); // exact prior state of the range
const next = step.type === 'addMark'
? addMarkInline(inline, step.from, step.to, step.mark)
: removeMarkInline(inline, step.from, step.to, step.mark.type);
return {
doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(next))),
// Length is unchanged, so restoring the saved slice over [from, to) is an exact inverse.
inverted: { type: 'replaceInline', blockId: step.blockId, from: step.from, to: step.to, content: removed },
};
}
case 'setAttrs': {
const block = blockById(doc, step.blockId);
if (!block)
return { doc, inverted: step };
return {
doc: mapBlock(doc, step.blockId, b => withAttrs(b, step.attrs)),
inverted: { type: 'setAttrs', blockId: step.blockId, attrs: block.attrs },
};
}
case 'setType': {
const block = blockById(doc, step.blockId);
if (!block)
return { doc, inverted: step };
return {
doc: mapBlock(doc, step.blockId, b => withType(b, step.blockType, step.attrs)),
inverted: { type: 'setType', blockId: step.blockId, blockType: block.type, attrs: block.attrs },
};
}
case 'splitBlock': {
const found = findBlock(doc, step.blockId);
if (!found)
return { doc, inverted: step };
const { node, index } = found;
const inline = nodeInline(node);
const head = normalizeInline(sliceInline(inline, 0, step.offset));
const tail = normalizeInline(sliceInline(inline, step.offset, inlineLength(inline)));
const newAttrs = step.newAttrs ?? (step.newType ? schema.defaultAttrs(step.newType) : node.attrs);
const newNode = createNode(step.newType ?? node.type, { id: step.newId, attrs: newAttrs, content: tail });
const content = [...doc.content.slice(0, index), withContent(node, head), newNode, ...doc.content.slice(index + 1)];
return {
doc: replaceBlocks(doc, content),
inverted: { type: 'mergeBlock', blockId: step.newId, intoId: step.blockId },
};
}
case 'mergeBlock': {
const source = findBlock(doc, step.blockId);
const target = findBlock(doc, step.intoId);
if (!source || !target)
return { doc, inverted: step };
const targetInline = nodeInline(target.node);
const splitOffset = inlineLength(targetInline);
// Drop source marks the target block disallows (e.g. merging styled text
// into a code-block must not smuggle in marks past `marks: 'none'`).
const targetSpec = schema.nodeSpec(target.node.type);
const sourceInline = targetSpec
? nodeInline(source.node).map(run => ({ text: run.text, marks: run.marks.filter(m => marksAllowed(targetSpec, m.type)) }))
: nodeInline(source.node);
const mergedInline = normalizeInline([...targetInline, ...sourceInline]);
const content = doc.content
.map(block => (block.id === step.intoId ? withContent(target.node, mergedInline) : block))
.filter(block => block.id !== step.blockId);
return {
doc: replaceBlocks(doc, content),
inverted: { type: 'splitBlock', blockId: step.intoId, offset: splitOffset, newId: source.node.id, newType: source.node.type, newAttrs: source.node.attrs },
};
}
case 'insertBlock': {
const index = clamp(step.index, 0, doc.content.length);
const content = [...doc.content.slice(0, index), step.node, ...doc.content.slice(index)];
return {
doc: replaceBlocks(doc, content),
inverted: { type: 'removeBlock', blockId: step.node.id },
};
}
case 'removeBlock': {
const found = findBlock(doc, step.blockId);
if (!found)
return { doc, inverted: step };
return {
doc: replaceBlocks(doc, doc.content.filter(block => block.id !== step.blockId)),
inverted: { type: 'insertBlock', node: found.node, index: found.index },
};
}
case 'moveBlock': {
const from = blockIndex(doc, step.blockId);
if (from === -1)
return { doc, inverted: step };
const arr = move(doc.content, from, step.toIndex);
return {
doc: replaceBlocks(doc, arr),
inverted: { type: 'moveBlock', blockId: step.blockId, toIndex: from },
};
}
case 'setDoc': {
// Replace the whole document (used to apply a remote CRDT snapshot).
return {
doc: step.doc,
inverted: { type: 'setDoc', doc },
};
}
}
}
+181
View File
@@ -0,0 +1,181 @@
import { clamp } from '@robonen/stdlib';
import type { Attrs, Inline, Mark, Marks, Node, Position, Selection, WritekitDocument } from '../model';
import { blockById, caret, createId, firstBlock, inlineLength, nodeInline } from '../model';
import type { Schema } from '../schema';
import type { WritekitState } from './writekit-state';
import type { Step } from './step';
import { applyStep } from './step';
/**
* A mutable builder that accumulates atomic {@link Step}s over a working copy of
* the document. Each builder method applies its step immediately (so later
* builders see prior effects) and records the exact inverse for undo. Dispatch
* turns the finished transaction into a new {@link WritekitState}.
*/
export class Transaction {
readonly before: WritekitState;
readonly steps: Step[] = [];
/** Inverse of each step, in application order (reversed when undoing). */
readonly inverted: Step[] = [];
readonly meta = new Map<string, unknown>();
/** Working document after the steps applied so far. */
doc: WritekitDocument;
/** Selection to apply after this transaction. */
selection: Selection;
/** `undefined` = leave stored marks to default handling; otherwise set them. */
storedMarks: Marks | null | undefined = undefined;
/** Id of the block created by the most recent {@link splitBlock}. */
lastSplitId: string | undefined;
private readonly schema: Schema;
constructor(state: WritekitState) {
this.before = state;
this.doc = state.doc;
this.selection = state.selection;
this.schema = state.schema;
}
/** Apply a raw step (also used by undo/redo to replay stored steps). */
step(step: Step): this {
const result = applyStep(this.doc, step, this.schema);
this.doc = result.doc;
this.steps.push(step);
this.inverted.push(result.inverted);
return this;
}
insertText(pos: Position, text: string, marks: Marks = []): this {
return this.step({ type: 'insertInline', blockId: pos.blockId, offset: pos.offset, content: text ? [{ text, marks }] : [] });
}
insertInline(pos: Position, content: Inline): this {
return this.step({ type: 'insertInline', blockId: pos.blockId, offset: pos.offset, content });
}
deleteText(blockId: string, from: number, to: number): this {
return this.step({ type: 'deleteText', blockId, from, to });
}
replaceInline(blockId: string, from: number, to: number, content: Inline): this {
return this.step({ type: 'replaceInline', blockId, from, to, content });
}
/** Replace a block's entire inline content (used by the input flush path). */
setBlockContent(blockId: string, content: Inline): this {
const block = blockById(this.doc, blockId);
const length = block ? inlineLength(nodeInline(block)) : 0;
return this.step({ type: 'replaceInline', blockId, from: 0, to: length, content });
}
addMark(blockId: string, from: number, to: number, mark: Mark): this {
return this.step({ type: 'addMark', blockId, from, to, mark });
}
removeMark(blockId: string, from: number, to: number, mark: Mark): this {
return this.step({ type: 'removeMark', blockId, from, to, mark });
}
/** Merge `attrs` into the block's existing attrs. */
setAttrs(blockId: string, attrs: Attrs): this {
const block = blockById(this.doc, blockId);
return this.step({ type: 'setAttrs', blockId, attrs: { ...(block?.attrs ?? {}), ...attrs } });
}
/** Convert a block to another type, preserving its inline content. */
setBlockType(blockId: string, type: string, attrs?: Attrs): this {
return this.step({ type: 'setType', blockId, blockType: type, attrs: attrs ?? this.schema.defaultAttrs(type) });
}
splitBlock(pos: Position, newType?: string, newAttrs?: Attrs, newId: string = createId()): this {
this.lastSplitId = newId;
return this.step({ type: 'splitBlock', blockId: pos.blockId, offset: pos.offset, newId, newType, newAttrs });
}
mergeBlock(blockId: string, intoId: string): this {
return this.step({ type: 'mergeBlock', blockId, intoId });
}
insertBlock(node: Node, index: number): this {
return this.step({ type: 'insertBlock', node, index });
}
removeBlock(blockId: string): this {
return this.step({ type: 'removeBlock', blockId });
}
moveBlock(blockId: string, toIndex: number): this {
return this.step({ type: 'moveBlock', blockId, toIndex });
}
/** Replace the whole document (used to apply a remote CRDT snapshot). */
setDoc(doc: WritekitDocument): this {
return this.step({ type: 'setDoc', doc });
}
setSelection(selection: Selection): this {
this.selection = selection;
return this;
}
setStoredMarks(marks: Marks | null): this {
this.storedMarks = marks;
return this;
}
setMeta(key: string, value: unknown): this {
this.meta.set(key, value);
return this;
}
getMeta(key: string): unknown {
return this.meta.get(key);
}
}
/** Start a transaction from the current writekit state. */
export function createTransaction(state: WritekitState): Transaction {
return new Transaction(state);
}
function clampPoint(point: Position, doc: WritekitDocument): Position | null {
const block = blockById(doc, point.blockId);
if (!block)
return null;
const length = inlineLength(nodeInline(block));
return { blockId: point.blockId, offset: clamp(point.offset, 0, length) };
}
function clampSelection(selection: Selection, doc: WritekitDocument): Selection {
if (selection.kind === 'node')
return selection;
const anchor = clampPoint(selection.anchor, doc);
const focus = clampPoint(selection.focus, doc);
if (!anchor || !focus) {
const first = firstBlock(doc);
return first ? caret(first.id, 0) : selection;
}
return { kind: 'text', anchor, focus };
}
/**
* Produce the next writekit state from a transaction. Stored marks are kept when
* explicitly set, cleared on any content change, and otherwise preserved.
*/
export function applyTransaction(state: WritekitState, tr: Transaction): WritekitState {
const storedMarks = tr.storedMarks !== undefined
? tr.storedMarks
: (tr.steps.length > 0 ? null : state.storedMarks);
return {
...state,
doc: tr.doc,
selection: clampSelection(tr.selection, tr.doc),
storedMarks,
};
}
+55
View File
@@ -0,0 +1,55 @@
import type { Marks, Selection, WritekitDocument } from '../model';
import { caret, createDoc, createNode, firstBlock, nodeSelection } from '../model';
import type { Registry } from '../registry';
import type { Schema } from '../schema';
import { normalizeDocument } from '../schema';
/** Immutable snapshot of everything the writekit renders and commands read. */
export interface WritekitState {
readonly doc: WritekitDocument;
readonly selection: Selection;
readonly schema: Schema;
readonly registry: Registry;
/** Marks to apply to the next typed character (toggle-before-type). */
readonly storedMarks: Marks | null;
}
export interface CreateWritekitStateOptions {
readonly registry: Registry;
readonly doc?: WritekitDocument;
readonly selection?: Selection;
}
function defaultBlockType(registry: Registry): string | undefined {
if (registry.hasBlock('paragraph'))
return 'paragraph';
for (const def of registry.listBlocks()) {
if (def.spec.content.kind === 'text')
return def.type;
}
return registry.listBlocks()[0]?.type;
}
/**
* Build the initial writekit state: normalize the document against the schema and
* ensure it has at least one editable block to place the caret in.
*/
export function createWritekitState(options: CreateWritekitStateOptions): WritekitState {
const { registry } = options;
const schema = registry.schema;
let doc = normalizeDocument(options.doc ?? createDoc(), schema);
if (doc.content.length === 0) {
const type = defaultBlockType(registry);
if (type)
doc = createDoc([createNode(type, { attrs: schema.defaultAttrs(type) })]);
}
const first = firstBlock(doc);
const selection = options.selection ?? (first ? caret(first.id, 0) : nodeSelection([]));
return { doc, selection, schema, registry, storedMarks: null };
}
+119
View File
@@ -0,0 +1,119 @@
import { PubSub } from '@robonen/stdlib';
import { selectionEq } from '../model';
import type { Command, Dispatch } from './command';
import type { WritekitState } from './writekit-state';
import type { HistoryOptions } from './history';
import { createHistory } from './history';
import type { Transaction } from './transaction';
import { applyTransaction, createTransaction } from './transaction';
/**
* Writekit event map. A `type` (not `interface`) so it satisfies the
* `Record<string, ...>` constraint of {@link PubSub}.
*/
export interface WritekitEvents {
/** Fired for every applied transaction (local, undo/redo, or remote). */
transaction: (tr: Transaction, next: WritekitState, prev: WritekitState) => void;
/** Fired when the document changed. */
docChange: (next: WritekitState, prev: WritekitState) => void;
/** Fired when the selection changed. */
selectionChange: (next: WritekitState, prev: WritekitState) => void;
}
/**
* The headless writekit controller: owns live state, the undo history, and a
* typed event bus. The Vue layer wraps it; the CRDT adapter subscribes to it.
*/
export interface Writekit {
readonly state: WritekitState;
dispatch: Dispatch;
/** Run a command against the current state, dispatching if it applies. */
command: (cmd: Command) => boolean;
undo: () => boolean;
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
on: <K extends keyof WritekitEvents>(event: K, listener: WritekitEvents[K]) => void;
off: <K extends keyof WritekitEvents>(event: K, listener: WritekitEvents[K]) => void;
destroy: () => void;
}
export interface CreateWritekitOptions {
readonly state: WritekitState;
readonly history?: HistoryOptions;
}
/** Create an {@link Writekit} around an initial state. */
export function createWritekit(options: CreateWritekitOptions): Writekit {
let state = options.state;
// A mapped type (not the `interface`) satisfies PubSub's `Record<string, …>` constraint.
const bus = new PubSub<{ [K in keyof WritekitEvents]: WritekitEvents[K] }>();
const history = createHistory(options.history);
const dispatch: Dispatch = (tr) => {
const prev = state;
const next = applyTransaction(prev, tr);
state = next;
if (tr.meta.get('addToHistory') !== false && tr.steps.length > 0) {
history.record({
steps: tr.steps,
inverted: tr.inverted,
selectionBefore: prev.selection,
selectionAfter: next.selection,
});
}
bus.emit('transaction', tr, next, prev);
if (next.doc !== prev.doc)
bus.emit('docChange', next, prev);
if (!selectionEq(next.selection, prev.selection))
bus.emit('selectionChange', next, prev);
};
return {
get state() {
return state;
},
dispatch,
command: cmd => cmd(state, dispatch),
undo() {
const entry = history.undo();
if (!entry)
return false;
const tr = createTransaction(state);
for (let i = entry.inverted.length - 1; i >= 0; i--)
tr.step(entry.inverted[i]!);
tr.setSelection(entry.selectionBefore).setMeta('addToHistory', false).setMeta('history', 'undo');
dispatch(tr);
return true;
},
redo() {
const entry = history.redo();
if (!entry)
return false;
const tr = createTransaction(state);
for (const step of entry.steps)
tr.step(step);
tr.setSelection(entry.selectionAfter).setMeta('addToHistory', false).setMeta('history', 'redo');
dispatch(tr);
return true;
},
canUndo: history.canUndo,
canRedo: history.canRedo,
on(event, listener) {
bus.on(event, listener);
},
off(event, listener) {
bus.off(event, listener);
},
destroy() {
bus.clear('transaction').clear('docChange').clear('selectionChange');
history.clear();
},
};
}