UNPKG

slate-history

Version:

An operation-based history implementation for Slate editors.

598 lines (587 loc) • 18.1 kB
import { isObject, Operation, Editor, Range, Path, Point, Transforms } from 'slate'; // eslint-disable-next-line no-redeclare const History = { /** * Check if a value is a `History` object. */ isHistory(value) { return isObject(value) && Array.isArray(value.redos) && Array.isArray(value.undos) && (value.redos.length === 0 || Operation.isOperationList(value.redos[0].operations)) && (value.undos.length === 0 || Operation.isOperationList(value.undos[0].operations)); } }; /** * Weakmaps for attaching state to the editor. */ const HISTORY = new WeakMap(); const SAVING = new WeakMap(); const MERGING = new WeakMap(); const SPLITTING_ONCE = new WeakMap(); // eslint-disable-next-line no-redeclare const HistoryEditor = { /** * Check if a value is a `HistoryEditor` object. */ isHistoryEditor(value) { return History.isHistory(value.history) && Editor.isEditor(value); }, /** * Get the merge flag's current value. */ isMerging(editor) { return MERGING.get(editor); }, /** * Get the splitting once flag's current value. */ isSplittingOnce(editor) { return SPLITTING_ONCE.get(editor); }, setSplittingOnce(editor, value) { SPLITTING_ONCE.set(editor, value); }, /** * Get the saving flag's current value. */ isSaving(editor) { return SAVING.get(editor); }, /** * Redo to the previous saved state. */ redo(editor) { editor.redo(); }, /** * Undo to the previous saved state. */ undo(editor) { editor.undo(); }, /** * Apply a series of changes inside a synchronous `fn`, These operations will * be merged into the previous history. */ withMerging(editor, fn) { const prev = HistoryEditor.isMerging(editor); MERGING.set(editor, true); try { fn(); } finally { MERGING.set(editor, prev); } }, /** * Apply a series of changes inside a synchronous `fn`, ensuring that the first * operation starts a new batch in the history. Subsequent operations will be * merged as usual. */ withNewBatch(editor, fn) { const prevMerging = HistoryEditor.isMerging(editor); const prevSplitting = HistoryEditor.isSplittingOnce(editor); MERGING.set(editor, true); SPLITTING_ONCE.set(editor, true); try { fn(); } finally { MERGING.set(editor, prevMerging); if (!prevSplitting) { SPLITTING_ONCE.delete(editor); } } }, /** * Apply a series of changes inside a synchronous `fn`, without merging any of * the new operations into previous save point in the history. */ withoutMerging(editor, fn) { const prev = HistoryEditor.isMerging(editor); MERGING.set(editor, false); try { fn(); } finally { MERGING.set(editor, prev); } }, /** * Apply a series of changes inside a synchronous `fn`, without saving any of * their operations into the history. */ withoutSaving(editor, fn) { const prev = HistoryEditor.isSaving(editor); SAVING.set(editor, false); try { fn(); } finally { SAVING.set(editor, prev); } } }; function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } function toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function toPropertyKey(t) { var i = toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; } function _defineProperty(e, r, t) { return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: true, configurable: true, writable: true }) : e[r] = t, e; } function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), true).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } const isChildOf = (path, parent) => path.length === parent.length + 1 && Path.isParent(parent, path); const mapPath = (path, by) => Path.transform(path, by); const mapSlot = (path, by) => { if ((by.type === 'remove_node' || by.type === 'merge_node') && Path.equals(by.path, path)) { return path; } if (by.type === 'move_node' && Path.equals(by.path, path)) { return Path.transform(path, { type: 'insert_node', path: by.newPath, node: { text: '' } }); } return mapPath(path, by); }; const mapOffset = ({ path, offset }, by, side) => { if (by.type === 'set_selection' || by.type === 'set_node') return { path, offset }; if (by.type === 'insert_node' || by.type === 'remove_node' || by.type === 'split_node' || by.type === 'merge_node') { if (isChildOf(by.path, path)) { const index = by.path[by.path.length - 1]; if (by.type === 'insert_node' || by.type === 'split_node') { const at = by.type === 'split_node' ? index + 1 : index; if (at < offset || at === offset && side === 'after') offset++; } else if (by.type === 'remove_node') { if (index < offset) offset--; } else if (index < offset) { offset--; } else if (index === offset) { return null; } const mapped = mapPath(path, by); return mapped && { path: mapped, offset }; } } if (by.type === 'move_node') { const from = isChildOf(by.path, path); const to = Path.transform(by.path, by); const into = to !== null && isChildOf(to, path); if (from || into) return null; } const point = Point.transform({ path, offset }, by, { affinity: side === 'after' ? 'forward' : 'backward' }); return point && { path: point.path, offset: point.offset }; }; const transformRemoveText = (op, by) => { const end = op.offset + op.text.length; if (by.type !== 'set_selection' && Path.equals(by.path, op.path)) { if (by.type === 'insert_text' && by.offset > op.offset && by.offset < end) { const split = by.offset - op.offset; return [_objectSpread(_objectSpread({}, op), {}, { text: op.text.slice(0, split) }), _objectSpread(_objectSpread({}, op), {}, { offset: op.offset + by.text.length, text: op.text.slice(split) })]; } if (by.type === 'remove_text') { const removedStart = by.offset; const removedEnd = by.offset + by.text.length; let text = ''; for (let i = op.offset; i < end; i++) { if (i < removedStart || i >= removedEnd) text += op.text[i - op.offset]; } if (!text) return []; const offset = op.offset - Math.max(0, Math.min(op.offset, removedEnd) - removedStart); return [_objectSpread(_objectSpread({}, op), {}, { offset, text })]; } if (by.type === 'split_node' && by.position > op.offset && by.position < end) { const split = by.position - op.offset; return [_objectSpread(_objectSpread({}, op), {}, { text: op.text.slice(0, split) }), _objectSpread(_objectSpread({}, op), {}, { path: Path.next(op.path), offset: 0, text: op.text.slice(split) })]; } } const start = mapOffset({ path: op.path, offset: op.offset }, by, 'after'); return start ? [_objectSpread(_objectSpread({}, op), {}, { path: start.path, offset: start.offset })] : []; }; const unchanged = (op, next) => { const a = op; const b = next; return a.text === b.text && a.offset === b.offset && a.position === b.position && (a.path === b.path || Path.equals(a.path, b.path)) && (a.newPath === b.newPath || Path.equals(a.newPath, b.newPath)); }; const transformOperation = (op, by, side) => { const result = transform(op, by, side); if ((result === null || result === void 0 ? void 0 : result.length) === 1 && unchanged(op, result[0])) return [op]; return result; }; const transform = (op, by, side) => { switch (op.type) { case 'set_selection': return [op]; case 'set_node': case 'remove_node': { const path = mapPath(op.path, by); return path ? [_objectSpread(_objectSpread({}, op), {}, { path })] : []; } case 'insert_node': { if (by.type === 'insert_node' && Path.equals(by.path, op.path) && side === 'before') { return [op]; } const path = mapSlot(op.path, by); return path ? [_objectSpread(_objectSpread({}, op), {}, { path })] : []; } case 'move_node': { const path = mapPath(op.path, by); if (!path) return []; const newPath = mapSlot(op.newPath, by); return newPath ? [_objectSpread(_objectSpread({}, op), {}, { path, newPath })] : null; } case 'insert_text': { const at = mapOffset({ path: op.path, offset: op.offset }, by, side); return at ? [_objectSpread(_objectSpread({}, op), {}, { path: at.path, offset: at.offset })] : []; } case 'remove_text': return transformRemoveText(op, by); case 'split_node': { if (!mapPath(op.path, by)) return []; const at = mapOffset({ path: op.path, offset: op.position }, by, 'before'); return at ? [_objectSpread(_objectSpread({}, op), {}, { path: at.path, position: at.offset })] : null; } case 'merge_node': { const path = mapPath(op.path, by); if (!path) return null; const previous = mapOffset({ path: Path.previous(op.path), offset: op.position }, by, 'after'); if (!previous || !Path.equals(previous.path, Path.previous(path))) { return null; } return [_objectSpread(_objectSpread({}, op), {}, { path, position: previous.offset })]; } } }; const opposite = side => side === 'after' ? 'before' : 'after'; const transformOperations = (ops, by, side) => { if (ops.length === 0 || by.length === 0) return [ops, by]; if (ops.length > 1) { const first = transformOperations([ops[0]], by, side); if (!first) return null; const rest = transformOperations(ops.slice(1), first[1], side); if (!rest) return null; return [first[0].concat(rest[0]), rest[1]]; } if (by.length > 1) { const first = transformOperations(ops, [by[0]], side); if (!first) return null; const rest = transformOperations(first[0], by.slice(1), side); if (!rest) return null; return [rest[0], first[1].concat(rest[1])]; } const op = transformOperation(ops[0], by[0], side); const other = transformOperation(by[0], ops[0], opposite(side)); return op && other ? [op, other] : null; }; const transformRange = (range, ops) => { let current = range; for (const op of ops) { if (!current) return null; current = Range.transform(current, op, { affinity: 'inward' }); } return current; }; const same = (a, b) => a.length === b.length && a.every((op, index) => op === b[index]); const rebaseRedos = (redos, op) => { let unsaved = [op]; for (let i = redos.length - 1; i >= 0 && unsaved.length; i--) { const batch = redos[i]; const result = transformOperations(batch.operations, unsaved, 'after'); if (!result || !result[0].length) { redos.splice(0, i + 1); return; } batch.selectionBefore = transformRange(batch.selectionBefore, unsaved); if (!same(result[0], batch.operations)) batch.operations = result[0]; unsaved = result[1]; } }; const rebaseUndos = (undos, op) => { let unsaved = [op]; for (let i = undos.length - 1; i >= 0 && unsaved.length; i--) { const batch = undos[i]; const inverse = batch.operations.map(Operation.inverse).reverse(); const result = transformOperations(inverse, unsaved, 'after'); if (!result) { unsaved = batch.operations.concat(unsaved); undos.splice(i, 1); continue; } unsaved = result[1]; if (!result[0].length) { undos.splice(i, 1); continue; } if (!same(result[0], inverse)) { batch.operations = result[0].map(Operation.inverse).reverse(); } batch.selectionBefore = transformRange(batch.selectionBefore, unsaved); } }; const rebaseHistory = (history, op) => { rebaseRedos(history.redos, op); rebaseUndos(history.undos, op); }; /** * The `withHistory` plugin keeps track of the operation history of a Slate * editor as operations are applied to it, using undo and redo stacks. * * If you are using TypeScript, you must extend Slate's CustomTypes to use * this plugin. * * See https://docs.slatejs.org/concepts/11-typescript to learn how. */ const withHistory = editor => { const e = editor; const { apply, normalize, setChildren } = e; let isSettled = true; let isReplaying = false; e.history = { undos: [], redos: [] }; e.redo = () => { const { history } = e; const { redos } = history; if (redos.length > 0) { const batch = redos[redos.length - 1]; if (batch.selectionBefore) { Transforms.select(e, batch.selectionBefore); } isReplaying = true; try { HistoryEditor.withoutSaving(e, () => { Editor.withoutNormalizing(e, () => { for (const op of batch.operations) { e.apply(op); } }); }); } finally { isReplaying = false; } history.redos.pop(); e.writeHistory('undos', batch); } }; e.undo = () => { const { history } = e; const { undos } = history; if (undos.length > 0) { const batch = undos[undos.length - 1]; isReplaying = true; try { HistoryEditor.withoutSaving(e, () => { Editor.withoutNormalizing(e, () => { const inverseOps = batch.operations.map(Operation.inverse).reverse(); for (const op of inverseOps) { e.apply(op); } if (batch.selectionBefore) { Transforms.select(e, batch.selectionBefore); } }); }); } finally { isReplaying = false; } e.writeHistory('redos', batch); history.undos.pop(); } }; e.apply = op => { const { operations, history } = e; const { undos } = history; const lastBatch = undos[undos.length - 1]; const lastOp = lastBatch && lastBatch.operations[lastBatch.operations.length - 1]; let save = HistoryEditor.isSaving(e); let merge = HistoryEditor.isMerging(e); if (save == null) { save = shouldSave(op); } if (save) { if (merge == null) { if (lastBatch == null) { merge = false; } else if (operations.includes(lastOp)) { merge = true; } else { merge = shouldMerge(op, lastOp); } } if (!merge && !isSettled && lastBatch && operations.includes(lastOp)) { merge = true; } if (HistoryEditor.isSplittingOnce(e)) { merge = false; HistoryEditor.setSplittingOnce(e, undefined); } if (lastBatch && merge) { lastBatch.operations.push(op); } else { const batch = { operations: [op], selectionBefore: e.selection }; e.writeHistory('undos', batch); } while (undos.length > 100) { undos.shift(); } history.redos = []; } else if (!isReplaying && op.type !== 'set_selection') { rebaseHistory(history, op); } isSettled = false; apply(op); }; e.normalize = options => { normalize(options); if (Editor.isNormalizing(e)) { isSettled = true; } }; e.setChildren = children => { const previous = e.history.undos[e.history.undos.length - 1]; const count = previous === null || previous === void 0 ? void 0 : previous.operations.length; HistoryEditor.withNewBatch(e, () => setChildren(children)); const current = e.history.undos[e.history.undos.length - 1]; if (current !== previous || (current === null || current === void 0 ? void 0 : current.operations.length) !== count) { HistoryEditor.setSplittingOnce(e, true); } }; e.writeHistory = (stack, batch) => { e.history[stack].push(batch); }; return e; }; /** * Check whether to merge an operation into the previous operation. */ const shouldMerge = (op, prev) => { if (prev && op.type === 'insert_text' && prev.type === 'insert_text' && op.offset === prev.offset + prev.text.length && Path.equals(op.path, prev.path)) { return true; } if (prev && op.type === 'remove_text' && prev.type === 'remove_text' && op.offset + op.text.length === prev.offset && Path.equals(op.path, prev.path)) { return true; } return false; }; /** * Check whether an operation needs to be saved to the history. */ const shouldSave = (op, prev) => { if (op.type === 'set_selection') { return false; } return true; }; export { HISTORY, History, HistoryEditor, MERGING, SAVING, SPLITTING_ONCE, withHistory }; //# sourceMappingURL=index.es.js.map