UNPKG

@antv/x6-plugin-history

Version:
652 lines 23.8 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { ObjectExt, FunctionExt, Basecoat, Model, } from '@antv/x6'; import './api'; export class History extends Basecoat { constructor(options = {}) { super(); this.name = 'history'; this.batchCommands = null; this.batchLevel = 0; this.lastBatchIndex = -1; this.freezed = false; this.stackSize = 0; // 0: not limit this.handlers = []; const { stackSize = 0 } = options; this.stackSize = stackSize; this.options = Util.getOptions(options); this.validator = new History.Validator({ history: this, cancelInvalid: this.options.cancelInvalid, }); } init(graph) { this.graph = graph; this.model = this.graph.model; this.clean(); this.startListening(); } // #region api isEnabled() { return !this.disabled; } enable() { if (this.disabled) { this.options.enabled = true; } } disable() { if (!this.disabled) { this.options.enabled = false; } } toggleEnabled(enabled) { if (enabled != null) { if (enabled !== this.isEnabled()) { if (enabled) { this.enable(); } else { this.disable(); } } } else if (this.isEnabled()) { this.disable(); } else { this.enable(); } return this; } undo(options = {}) { if (!this.disabled) { const cmd = this.undoStack.pop(); if (cmd) { this.revertCommand(cmd, options); this.redoStack.push(cmd); this.notify('undo', cmd, options); } } return this; } redo(options = {}) { if (!this.disabled) { const cmd = this.redoStack.pop(); if (cmd) { this.applyCommand(cmd, options); this.undoStackPush(cmd); this.notify('redo', cmd, options); } } return this; } /** * Same as `undo()` but does not store the undo-ed command to the * `redoStack`. Canceled command therefore cannot be redo-ed. */ cancel(options = {}) { if (!this.disabled) { const cmd = this.undoStack.pop(); if (cmd) { this.revertCommand(cmd, options); this.redoStack = []; this.notify('cancel', cmd, options); } } return this; } getSize() { return this.stackSize; } getUndoRemainSize() { const ul = this.undoStack.length; return this.stackSize - ul; } getUndoSize() { return this.undoStack.length; } getRedoSize() { return this.redoStack.length; } canUndo() { return !this.disabled && this.undoStack.length > 0; } canRedo() { return !this.disabled && this.redoStack.length > 0; } clean(options = {}) { this.undoStack = []; this.redoStack = []; this.notify('clean', null, options); return this; } // #endregion get disabled() { return this.options.enabled !== true; } validate(events, ...callbacks) { this.validator.validate(events, ...callbacks); return this; } startListening() { this.model.on('batch:start', this.initBatchCommand, this); this.model.on('batch:stop', this.storeBatchCommand, this); if (this.options.eventNames) { this.options.eventNames.forEach((name, index) => { this.handlers[index] = this.addCommand.bind(this, name); this.model.on(name, this.handlers[index]); }); } this.validator.on('invalid', (args) => this.trigger('invalid', args)); } stopListening() { this.model.off('batch:start', this.initBatchCommand, this); this.model.off('batch:stop', this.storeBatchCommand, this); if (this.options.eventNames) { this.options.eventNames.forEach((name, index) => { this.model.off(name, this.handlers[index]); }); this.handlers.length = 0; } this.validator.off('invalid'); } createCommand(options) { return { batch: options ? options.batch : false, data: {}, }; } revertCommand(cmd, options) { this.freezed = true; const cmds = Array.isArray(cmd) ? Util.sortBatchCommands(cmd) : [cmd]; for (let i = cmds.length - 1; i >= 0; i -= 1) { const cmd = cmds[i]; const localOptions = Object.assign(Object.assign({}, options), ObjectExt.pick(cmd.options, this.options.revertOptionsList || [])); this.executeCommand(cmd, true, localOptions); } this.freezed = false; } applyCommand(cmd, options) { this.freezed = true; const cmds = Array.isArray(cmd) ? Util.sortBatchCommands(cmd) : [cmd]; for (let i = 0; i < cmds.length; i += 1) { const cmd = cmds[i]; const localOptions = Object.assign(Object.assign({}, options), ObjectExt.pick(cmd.options, this.options.applyOptionsList || [])); this.executeCommand(cmd, false, localOptions); } this.freezed = false; } executeCommand(cmd, revert, options) { const model = this.model; // const cell = cmd.modelChange ? model : model.getCell(cmd.data.id!) const cell = model.getCell(cmd.data.id); const event = cmd.event; if ((Util.isAddEvent(event) && revert) || (Util.isRemoveEvent(event) && !revert)) { cell && cell.remove(options); } else if ((Util.isAddEvent(event) && !revert) || (Util.isRemoveEvent(event) && revert)) { const data = cmd.data; if (data.node) { model.addNode(data.props, options); } else if (data.edge) { model.addEdge(data.props, options); } } else if (Util.isChangeEvent(event)) { const data = cmd.data; const key = data.key; if (key && cell) { const value = revert ? data.prev[key] : data.next[key]; if (data.key === 'attrs') { const hasUndefinedAttr = this.ensureUndefinedAttrs(value, revert ? data.next[key] : data.prev[key]); if (hasUndefinedAttr) { // recognize a `dirty` flag and re-render itself in order to remove // the attribute from SVGElement. options.dirty = true; } } cell.prop(key, value, options); } } else { const executeCommand = this.options.executeCommand; if (executeCommand) { FunctionExt.call(executeCommand, this, cmd, revert, options); } } } addCommand(event, args) { if (this.freezed || this.disabled) { return; } const eventArgs = args; const options = eventArgs.options || {}; if (options.dryrun) { return; } if ((Util.isAddEvent(event) && this.options.ignoreAdd) || (Util.isRemoveEvent(event) && this.options.ignoreRemove) || (Util.isChangeEvent(event) && this.options.ignoreChange)) { return; } // before // ------ const before = this.options.beforeAddCommand; if (before != null && FunctionExt.call(before, this, event, args) === false) { return; } if (event === 'cell:change:*') { // eslint-disable-next-line event = `cell:change:${eventArgs.key}`; } const cell = eventArgs.cell; const isModelChange = Model.isModel(cell); let cmd; if (this.batchCommands) { // In most cases we are working with same object, doing // same action etc. translate an object piece by piece. cmd = this.batchCommands[Math.max(this.lastBatchIndex, 0)]; // Check if we are start working with new object or performing different // action with it. Note, that command is uninitialized when lastCmdIndex // equals -1. In that case we are done, command we were looking for is // already set const diffId = (isModelChange && !cmd.modelChange) || cmd.data.id !== cell.id; const diffName = cmd.event !== event; if (this.lastBatchIndex >= 0 && (diffId || diffName)) { // Trying to find command first, which was performing same // action with the object as we are doing now with cell. const index = this.batchCommands.findIndex((cmd) => ((isModelChange && cmd.modelChange) || cmd.data.id === cell.id) && cmd.event === event); if (index < 0 || Util.isAddEvent(event) || Util.isRemoveEvent(event)) { cmd = this.createCommand({ batch: true }); } else { cmd = this.batchCommands[index]; this.batchCommands.splice(index, 1); } this.batchCommands.push(cmd); this.lastBatchIndex = this.batchCommands.length - 1; } } else { cmd = this.createCommand({ batch: false }); } // add & remove // ------------ if (Util.isAddEvent(event) || Util.isRemoveEvent(event)) { const data = cmd.data; cmd.event = event; cmd.options = options; data.id = cell.id; data.props = ObjectExt.cloneDeep(cell.toJSON()); if (cell.isEdge()) { data.edge = true; } else if (cell.isNode()) { data.node = true; } return this.push(cmd, options); } // change:* // -------- if (Util.isChangeEvent(event)) { const key = args.key; const data = cmd.data; if (!cmd.batch || !cmd.event) { // Do this only once. Set previous data and action (also // serves as a flag so that we don't repeat this branche). cmd.event = event; cmd.options = options; data.key = key; if (data.prev == null) { data.prev = {}; } data.prev[key] = ObjectExt.cloneDeep(cell.previous(key)); if (isModelChange) { cmd.modelChange = true; } else { data.id = cell.id; } } if (data.next == null) { data.next = {}; } data.next[key] = ObjectExt.cloneDeep(cell.prop(key)); return this.push(cmd, options); } // others // ------ const afterAddCommand = this.options.afterAddCommand; if (afterAddCommand) { FunctionExt.call(afterAddCommand, this, event, args, cmd); } this.push(cmd, options); } /** * Gather multiple changes into a single command. These commands could * be reverted with single `undo()` call. From the moment the function * is called every change made on model is not stored into the undoStack. * Changes are temporarily kept until `storeBatchCommand()` is called. */ // eslint-disable-next-line initBatchCommand(options) { if (this.freezed) { return; } if (this.batchCommands) { this.batchLevel += 1; } else { this.batchCommands = [this.createCommand({ batch: true })]; this.batchLevel = 0; this.lastBatchIndex = -1; } } /** * Store changes temporarily kept in the undoStack. You have to call this * function as many times as `initBatchCommand()` been called. */ storeBatchCommand(options) { if (this.freezed) { return; } if (this.batchCommands && this.batchLevel <= 0) { const cmds = this.filterBatchCommand(this.batchCommands); if (cmds.length > 0) { this.redoStack = []; this.undoStackPush(cmds); this.consolidateCommands(); this.notify('add', cmds, options); } this.batchCommands = null; this.lastBatchIndex = -1; this.batchLevel = 0; } else if (this.batchCommands && this.batchLevel > 0) { this.batchLevel -= 1; } } filterBatchCommand(batchCommands) { let cmds = batchCommands.slice(); const result = []; while (cmds.length > 0) { const cmd = cmds.shift(); const evt = cmd.event; const id = cmd.data.id; if (evt != null && (id != null || cmd.modelChange)) { if (Util.isAddEvent(evt)) { const index = cmds.findIndex((c) => Util.isRemoveEvent(c.event) && c.data.id === id); if (index >= 0) { cmds = cmds.filter((c, i) => index < i || c.data.id !== id); continue; } } else if (Util.isRemoveEvent(evt)) { const index = cmds.findIndex((c) => Util.isAddEvent(c.event) && c.data.id === id); if (index >= 0) { cmds.splice(index, 1); continue; } } else if (Util.isChangeEvent(evt)) { const data = cmd.data; if (ObjectExt.isEqual(data.prev, data.next)) { continue; } } else { // pass } result.push(cmd); } } return result; } notify(event, cmd, options) { const cmds = cmd == null ? null : Array.isArray(cmd) ? cmd : [cmd]; this.emit(event, { cmds, options }); this.graph.trigger(`history:${event}`, { cmds, options }); this.emit('change', { cmds, options }); this.graph.trigger('history:change', { cmds, options }); } push(cmd, options) { this.redoStack = []; if (cmd.batch) { this.lastBatchIndex = Math.max(this.lastBatchIndex, 0); this.emit('batch', { cmd, options }); } else { this.undoStackPush(cmd); this.consolidateCommands(); this.notify('add', cmd, options); } } /** * Conditionally combine multiple undo items into one. * * Currently this is only used combine a `cell:changed:position` event * followed by multiple `cell:change:parent` and `cell:change:children` * events, such that a "move + embed" action can be undone in one step. * * See https://github.com/antvis/X6/issues/2421 * * This is an ugly WORKAROUND. It does not solve deficiencies in the batch * system itself. */ consolidateCommands() { var _a; const lastCommandGroup = this.undoStack[this.undoStack.length - 1]; const penultimateCommandGroup = this.undoStack[this.undoStack.length - 2]; // We are looking for at least one cell:change:parent // and one cell:change:children if (!Array.isArray(lastCommandGroup)) { return; } const eventTypes = new Set(lastCommandGroup.map((cmd) => cmd.event)); if (eventTypes.size !== 2 || !eventTypes.has('cell:change:parent') || !eventTypes.has('cell:change:children')) { return; } // We are looking for events from user interactions if (!lastCommandGroup.every((cmd) => { var _a; return cmd.batch && ((_a = cmd.options) === null || _a === void 0 ? void 0 : _a.ui); })) { return; } // We are looking for a command group with exactly one event, whose event // type is cell:change:position, and is from user interactions if (!Array.isArray(penultimateCommandGroup) || penultimateCommandGroup.length !== 1) { return; } const maybePositionChange = penultimateCommandGroup[0]; if (maybePositionChange.event !== 'cell:change:position' || !((_a = maybePositionChange.options) === null || _a === void 0 ? void 0 : _a.ui)) { return; } // Actually consolidating the commands we get penultimateCommandGroup.push(...lastCommandGroup); this.undoStack.pop(); } undoStackPush(cmd) { if (this.stackSize === 0) { this.undoStack.push(cmd); return; } if (this.undoStack.length >= this.stackSize) { this.undoStack.shift(); } this.undoStack.push(cmd); } ensureUndefinedAttrs(newAttrs, oldAttrs) { let hasUndefinedAttr = false; if (newAttrs !== null && oldAttrs !== null && typeof newAttrs === 'object' && typeof oldAttrs === 'object') { Object.keys(oldAttrs).forEach((key) => { // eslint-disable-next-line no-prototype-builtins if (newAttrs[key] === undefined && oldAttrs[key] !== undefined) { newAttrs[key] = undefined; hasUndefinedAttr = true; } else if (typeof newAttrs[key] === 'object' && typeof oldAttrs[key] === 'object') { hasUndefinedAttr = this.ensureUndefinedAttrs(newAttrs[key], oldAttrs[key]); } }); } return hasUndefinedAttr; } dispose() { this.validator.dispose(); this.clean(); this.stopListening(); this.off(); } } __decorate([ Basecoat.dispose() ], History.prototype, "dispose", null); (function (History) { /** * Runs a set of callbacks to determine if a command is valid. This is * useful for checking if a certain action in your application does * lead to an invalid state of the graph. */ class Validator extends Basecoat { constructor(options) { super(); this.map = {}; this.command = options.history; this.cancelInvalid = options.cancelInvalid !== false; this.command.on('add', this.onCommandAdded, this); } onCommandAdded({ cmds }) { return Array.isArray(cmds) ? cmds.every((cmd) => this.isValidCommand(cmd)) : this.isValidCommand(cmds); } isValidCommand(cmd) { if (cmd.options && cmd.options.validation === false) { return true; } const callbacks = (cmd.event && this.map[cmd.event]) || []; let handoverErr = null; callbacks.forEach((routes) => { let i = 0; const rollup = (err) => { const fn = routes[i]; i += 1; try { if (fn) { fn(err, cmd, rollup); } else { handoverErr = err; return; } } catch (err) { rollup(err); } }; rollup(handoverErr); }); if (handoverErr) { if (this.cancelInvalid) { this.command.cancel(); } this.emit('invalid', { err: handoverErr }); return false; } return true; } validate(events, ...callbacks) { const evts = Array.isArray(events) ? events : events.split(/\s+/); callbacks.forEach((callback) => { if (typeof callback !== 'function') { throw new Error(`${evts.join(' ')} requires callback functions.`); } }); evts.forEach((event) => { if (this.map[event] == null) { this.map[event] = []; } this.map[event].push(callbacks); }); return this; } dispose() { this.command.off('add', this.onCommandAdded, this); } } __decorate([ Basecoat.dispose() ], Validator.prototype, "dispose", null); History.Validator = Validator; })(History || (History = {})); var Util; (function (Util) { function isAddEvent(event) { return event === 'cell:added'; } Util.isAddEvent = isAddEvent; function isRemoveEvent(event) { return event === 'cell:removed'; } Util.isRemoveEvent = isRemoveEvent; function isChangeEvent(event) { return event != null && event.startsWith('cell:change:'); } Util.isChangeEvent = isChangeEvent; function getOptions(options) { const reservedNames = [ 'cell:added', 'cell:removed', 'cell:change:*', ]; const batchEvents = ['batch:start', 'batch:stop']; const eventNames = options.eventNames ? options.eventNames.filter((event) => !(Util.isChangeEvent(event) || reservedNames.includes(event) || batchEvents.includes(event))) : reservedNames; return Object.assign(Object.assign({ enabled: true }, options), { eventNames, applyOptionsList: options.applyOptionsList || ['propertyPath'], revertOptionsList: options.revertOptionsList || ['propertyPath'] }); } Util.getOptions = getOptions; function sortBatchCommands(cmds) { const results = []; for (let i = 0, ii = cmds.length; i < ii; i += 1) { const cmd = cmds[i]; let index = null; if (Util.isAddEvent(cmd.event)) { const id = cmd.data.id; for (let j = 0; j < i; j += 1) { if (cmds[j].data.id === id) { index = j; break; } } } if (index !== null) { results.splice(index, 0, cmd); } else { results.push(cmd); } } return results; } Util.sortBatchCommands = sortBatchCommands; })(Util || (Util = {})); //# sourceMappingURL=index.js.map