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:
@@ -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: [] }]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './command';
|
||||
export * from './writekit-state';
|
||||
export * from './step';
|
||||
export * from './transaction';
|
||||
export * from './history';
|
||||
export * from './writekit';
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user