Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 81 additions & 16 deletions src/doc/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type DeleteOperation = Readonly<{
}>;

const TYPE_INSERT_TEXT = "insert_text";
type InsertOperation = Readonly<{
type InsertTextOperation = Readonly<{
type: typeof TYPE_INSERT_TEXT;
at: Position;
text: string;
Expand All @@ -43,7 +43,7 @@ type SetAttrOperation = Readonly<{

export type Operation =
| DeleteOperation
| InsertOperation
| InsertTextOperation
| InsertNodeOperation
| SetAttrOperation;

Expand Down Expand Up @@ -136,6 +136,23 @@ const getNodeSize = (node: InlineNode): number =>
export const getLineSize = (line: readonly InlineNode[]): number =>
line.reduce((acc: number, n) => acc + getNodeSize(n), 0);

const movePositionWithFragment = (
position: Position,
fragment: Fragment,
insertedAt: Position = position,
): Position => {
const lineLength = fragment.length;
const lineDiff = lineLength - 1;
return [
movePath(position[0], lineDiff),
position[1] +
(comparePath(position[0], insertedAt[0]) === 0
? getLineSize(fragment[lineLength - 1]!) -
(lineDiff === 0 ? 0 : insertedAt[1])
: 0),
];
};

const normalize = <T extends InlineNode>(
array: T[],
start: number = 0,
Expand Down Expand Up @@ -315,7 +332,61 @@ const isValidPosition = (doc: DocNode, [path, offset]: Position): boolean => {
return false;
};

export const rebasePosition = (position: Position, op: Operation): Position => {
/**
* @internal
*/
export function* invertOperation<T extends DocNode>(
op: Operation,
beforeDoc: T,
): Generator<Operation> {
switch (op.type) {
case TYPE_DELETE: {
yield {
type: TYPE_INSERT_NODE,
at: op.start,
fragment: sliceDoc(beforeDoc, op.start, op.end),
};
break;
}
case TYPE_INSERT_TEXT: {
yield {
type: TYPE_DELETE,
start: op.at,
end: movePositionWithFragment(op.at, stringToFragment(op.text)),
};
break;
}
case TYPE_INSERT_NODE: {
yield {
type: TYPE_DELETE,
start: op.at,
end: movePositionWithFragment(op.at, op.fragment),
};
break;
}
case TYPE_SET_ATTR: {
const { start, end, key } = op;
for (const b of sliceDoc(beforeDoc, start, end)) {
for (const n of b) {
// TODO range is wrong
yield {
type: TYPE_SET_ATTR,
start,
end,
key,
value: n[key as keyof typeof n],
};
}
}
break;
}
default: {
return op satisfies never;
}
}
}

const rebasePosition = (position: Position, op: Operation): Position => {
switch (op.type) {
case TYPE_DELETE: {
const { start, end } = op;
Expand All @@ -342,22 +413,16 @@ export const rebasePosition = (position: Position, op: Operation): Position => {
case TYPE_INSERT_TEXT:
case TYPE_INSERT_NODE: {
const at = op.at;
const lines =
op.type === TYPE_INSERT_TEXT ? stringToFragment(op.text) : op.fragment;

const lineLength = lines.length;
const lineDiff = lineLength - 1;

if (comparePosition(position, at) !== -1) {
// pos <= position
return [
movePath(position[0], lineDiff),
position[1] +
(comparePath(position[0], at[0]) === 0
? getLineSize(lines[lineLength - 1]!) -
(lineDiff === 0 ? 0 : at[1])
: 0),
];
return movePositionWithFragment(
position,
op.type === TYPE_INSERT_TEXT
? stringToFragment(op.text)
: op.fragment,
at,
);
}
break;
}
Expand Down
48 changes: 10 additions & 38 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,42 +221,9 @@ export const createEditor = <
throw new Error(initialError);
}

const keydownHandlers: KeyboardHandler[] = [
hotkey(
"z",
() => {
if (!readonly) {
const nextHistory = history.undo();
if (nextHistory) {
doc = nextHistory[0];
updateSelection(nextHistory[1]);
onChange(doc);
}
}
},
{ mod: true },
),
hotkey(
"z",
() => {
if (!readonly) {
const nextHistory = history.redo();
if (nextHistory) {
doc = nextHistory[0];
updateSelection(nextHistory[1]);
onChange(doc);
}
}
},
{ mod: true, shift: true },
),
];
if (keyboard) {
keydownHandlers.push(...keyboard);
}

const applyHooks: Exclude<EditorPlugin["apply"], undefined>[] = [];
const mountHooks: Exclude<EditorPlugin["mount"], undefined>[] = [];

if (plugins) {
plugins.forEach(({ apply, mount }) => {
if (apply) {
Expand Down Expand Up @@ -285,7 +252,6 @@ export const createEditor = <
const commit = () => {
if (transactions.length) {
const currentDoc = doc;
const ops: Operation[] = [];
const length = applyHooks.length;

let tr: Transaction | undefined;
Expand All @@ -312,7 +278,6 @@ export const createEditor = <
if (!isUnsafeOperation(op) || validate(nextDoc, onError)) {
doc = nextDoc;
selection = nextSelection;
ops.push(op);
}
} catch (e) {
// rollback
Expand All @@ -337,7 +302,6 @@ export const createEditor = <
}

if (!is(currentDoc, doc)) {
history.change(doc, ops);
onChange(doc);
}
}
Expand Down Expand Up @@ -725,7 +689,15 @@ export const createEditor = <
},
};

const history = createHistory<T>(doc);
const history = createHistory(editor);
applyHooks.unshift(history.apply);
const keydownHandlers: KeyboardHandler[] = [
hotkey("z", history.undo, { mod: true }),
hotkey("z", history.redo, { mod: true, shift: true }),
];
if (keyboard) {
keydownHandlers.push(...keyboard);
}

return editor;
};
112 changes: 68 additions & 44 deletions src/history.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,99 @@
import { rebasePosition, type Operation } from "./doc/edit.js";
import { invertOperation, type Operation, Transaction } from "./doc/edit.js";
import type { SelectionSnapshot } from "./doc/types.js";
import type { Editor } from "./editor.js";
import type { EditorPlugin } from "./plugins/types.js";
import { is, microtask } from "./utils.js";

const MAX_HISTORY_LENGTH = 500;
const MAX_HISTORY_LENGTH = 100;
const BATCH_HISTORY_TIME = 500;

const getOperationSelection = (op: Operation): SelectionSnapshot => {
return "at" in op ? [op.at, op.at] : [op.start, op.end];
};

type History = [ops: Operation[], invertedOps: Operation[]];

/**
* @internal
*/
export const createHistory = <T>(initialDoc: T) => {
let index = 0;
export const createHistory = (editor: Editor) => {
let index = -1;
let prevTime = 0;
const now = Date.now;
const histories: [T, Operation[]][] = [[initialDoc, []]];
let undoOrRedoing = false;
let history: History | undefined;

const get = () => histories[index]!;
const now = Date.now;
const histories: History[] = [];

const isUndoable = (): boolean => {
return index > 0;
return index >= 0;
};

const isRedoable = (): boolean => {
return index < histories.length - 1;
return index < histories.length;
};

return {
change: (doc: T, ops: Operation[]) => {
const time = now();
if (index === 0 || time - prevTime >= BATCH_HISTORY_TIME) {
index++;
if (index >= histories.length) {
histories.push([doc, []]);
} else {
histories[index]![1].splice(0);
}
}
prevTime = time;
histories[index]![0] = doc;
histories[index]![1].push(...ops);
histories.splice(index + 1);
if (index > MAX_HISTORY_LENGTH) {
index--;
histories.shift();
const flush = () => {
if (!history) return;

const time = now();
if (index === -1 || time - prevTime >= BATCH_HISTORY_TIME) {
index++;
if (index >= histories.length) {
histories.push([[], []]);
} else {
histories[index]![0].splice(0);
histories[index]![1].splice(0);
}
},
undo: (): [T, SelectionSnapshot] | undefined => {
}
prevTime = time;
histories[index]![0].push(...history[0]);
histories[index]![1].push(...history[1]);
histories.splice(index + 1);
if (index > MAX_HISTORY_LENGTH) {
index--;
histories.shift();
}

history = undefined;
};

return {
undo: (): void => {
if (isUndoable()) {
const ops = get()[1];
const [ops, inverted] = histories[index]!;
index--;
const doc = get()[0];
return [doc, getOperationSelection(ops[0]!)];
} else {
return;
undoOrRedoing = true;
editor.apply(new Transaction(inverted.slice().reverse()), true);
editor.selection = getOperationSelection(ops[0]!);
undoOrRedoing = false;
}
},
redo: (): [T, SelectionSnapshot] | undefined => {
redo: (): void => {
if (isRedoable()) {
index++;
const [doc, ops] = get();
const last = ops[ops.length - 1]!;
const sel = getOperationSelection(last);
return [
doc,
[rebasePosition(sel[0], last), rebasePosition(sel[1], last)],
];
} else {
return;
const [ops] = histories[index]!;
undoOrRedoing = true;
editor.selection = getOperationSelection(ops[ops.length - 1]!);
editor.apply(new Transaction(ops), true);
undoOrRedoing = false;
}
},
apply: ((op, next) => {
if (undoOrRedoing) return;

const doc = editor.doc;
next(op);

if (!is(doc, editor.doc)) {
if (!history) {
history = [[], []];
microtask(flush);
}

history[0].push(op);
history[1].push(...invertOperation(op, doc));
}
}) as Exclude<EditorPlugin["apply"], undefined>,
};
};
Loading