typewriter-editor
Version:
A rich text editor using the Delta format with decorations and rendered with a tiny virtual dom
183 lines (182 loc) • 6.48 kB
JavaScript
import { Delta, TextChange, TextDocument } from '@typewriter/document';
import { Editor, EditorChangeEvent } from '../Editor';
import { Source } from '../Source';
// Default history module
export const history = initHistory();
/**
* History is a view module for storing user changes and undoing/redoing those changes.
*
* Stores history for all user-generated changes. Like-changes will be combined until a selection or a delay timeout
* cuts off the combining. E.g. if a user types "Hello" the 5 changes will be combined into one history entry. If
* the user moves the cursor somewhere and then back to the end and types " World" the next 6 changes are combined
* separately from the first 5 because selection changes add a cutoff history entries.
*
* The default options can be overridden by passing alternatives to history. To add a timeout to force a cutoff after
* so many milliseconds set a delay like this:
* ```js
* const modules = {
* history: history({ delay: 4000 })
* };
* ```
*/
export function initHistory(initOptions = {}) {
return function (editor) {
let lastRecorded = 0;
let lastAction = '';
let ignoreChange = false;
let stack = undoStack();
const options = { maxStack: 500, delay: 0, unrecordedSources: new Set(), ...initOptions };
function onBeforeInput(event) {
if (event.inputType === 'historyUndo') {
event.preventDefault();
undo();
}
else if (event.inputType === 'historyRedo') {
event.preventDefault();
redo();
}
}
function undo() {
action('undo', 'redo');
}
function redo() {
action('redo', 'undo');
}
function hasUndo() {
return stack.undo.length > 0;
}
function hasRedo() {
return stack.redo.length > 0;
}
function cutoffHistory() {
lastRecorded = 0;
}
function clearHistory() {
stack = undoStack();
}
function action(source, dest) {
if (stack[source].length === 0)
return;
const entry = stack[source].pop();
stack[dest].push(entry);
cutoffHistory();
ignoreChange = true;
editor.update(entry[source], Source.history);
ignoreChange = false;
}
function record(change, oldDoc) {
const timestamp = Date.now();
const action = getAction(change);
stack.redo.length = 0;
const undo = new TextChange(null, change.delta.invert(oldDoc.toDelta()), oldDoc.selection);
// Break combining if actions are different (e.g. a delete then an insert should break it)
if (!action || lastAction !== action)
cutoffHistory();
lastAction = action;
if (lastRecorded && (!options.delay || lastRecorded + options.delay > timestamp) && stack.undo.length) {
// Combine with the last change
const entry = stack.undo[stack.undo.length - 1];
entry.redo.delta = entry.redo.delta.compose(change.delta);
entry.redo.selection = change.selection;
entry.undo.delta = undo.delta.compose(entry.undo.delta);
}
else {
const redo = new TextChange(null, change.delta, change.selection);
lastRecorded = timestamp;
stack.undo.push({ redo, undo });
}
if (stack.undo.length > options.maxStack) {
stack.undo.shift();
}
}
function onChange({ change, old, source }) {
if (!change)
return clearHistory();
if (ignoreChange)
return;
if (!change.contentChanged)
return cutoffHistory();
if (source !== Source.api && !options.unrecordedSources.has(source)) {
record(change, old);
}
else {
transformHistoryStack(stack, change);
}
}
// Advanced, only use this if the stack matches the document
// e.g. use transformStack when changes come in for a document that isn't loaded
function setStack(value) {
stack = value;
}
function getStack() {
return stack;
}
return {
options,
hasUndo,
hasRedo,
undo,
redo,
cutoffHistory,
clearHistory,
setStack,
getStack,
getActive() {
return { undo: hasUndo(), redo: hasRedo() };
},
commands: {
undo,
redo,
},
shortcuts: {
'win:Ctrl+Z': 'undo',
'mac:Cmd+Z': 'undo',
'win:Ctrl+Y': 'redo',
'mac:Cmd+Shift+Z': 'redo',
},
init() {
editor.on('change', onChange);
editor.root.addEventListener('beforeinput', onBeforeInput);
},
destroy() {
editor.off('change', onChange);
editor.root.removeEventListener('beforeinput', onBeforeInput);
},
};
};
}
export function undoStack() {
return {
undo: [],
redo: [],
};
}
export function transformHistoryStack(stack, delta) {
const change = delta.ops ? new TextChange(null, delta) : delta;
stack.undo.forEach(entry => {
entry.undo = change.transform(entry.undo, true);
entry.redo = change.transform(entry.redo, true);
});
stack.redo.forEach(entry => {
entry.undo = change.transform(entry.undo, true);
entry.redo = change.transform(entry.redo, true);
});
}
function getAction(change) {
const { ops } = change.delta;
let head = 0, tail = ops.length - 1;
if (ops[head].retain && !ops[head].attributes)
head++;
if (ops[tail].retain === 1 && ops[tail].attributes?.id)
tail--;
if (head === tail) {
const changeOp = ops[head];
if (changeOp.delete)
return 'delete';
if (changeOp.insert === '\n')
return 'newline';
if (typeof changeOp.insert === 'string')
return 'insert';
}
return '';
}