UNPKG

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
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 ''; }