UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering.

778 lines (679 loc) 20.6 kB
import { KeyValue } from '../types' import { ObjectExt, FunctionExt } from '../util' import { Basecoat, IDisablable } from '../common' import { Cell } from '../model/cell' import { Model } from '../model/model' import { Graph } from './graph' export class HistoryManager extends Basecoat<HistoryManager.EventArgs> implements IDisablable { public readonly model: Model public readonly graph: Graph public readonly options: HistoryManager.CommonOptions public readonly validator: HistoryManager.Validator protected redoStack: HistoryManager.Commands[] protected undoStack: HistoryManager.Commands[] protected batchCommands: HistoryManager.Command[] | null = null protected batchLevel = 0 protected lastBatchIndex = -1 protected freezed = false protected readonly handlers: (<T extends HistoryManager.ModelEvents>( event: T, args: Model.EventArgs[T], ) => any)[] = [] constructor(options: HistoryManager.Options) { super() this.graph = options.graph this.model = options.graph.model this.options = Util.getOptions(options) this.validator = new HistoryManager.Validator({ history: this, cancelInvalid: this.options.cancelInvalid, }) this.clean() this.startListening() } get disabled() { return this.options.enabled !== true } enable() { if (this.disabled) { this.options.enabled = true } } disable() { if (!this.disabled) { this.options.enabled = false } } undo(options: KeyValue = {}) { 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: KeyValue = {}) { if (!this.disabled) { const cmd = this.redoStack.pop() if (cmd) { this.applyCommand(cmd, options) this.undoStack.push(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: KeyValue = {}) { if (!this.disabled) { const cmd = this.undoStack.pop() if (cmd) { this.revertCommand(cmd, options) this.redoStack = [] this.notify('cancel', cmd, options) } } return this } clean(options: KeyValue = {}) { this.undoStack = [] this.redoStack = [] this.notify('clean', null, options) return this } canUndo() { return !this.disabled && this.undoStack.length > 0 } canRedo() { return !this.disabled && this.redoStack.length > 0 } validate( events: string | string[], ...callbacks: HistoryManager.Validator.Callback[] ) { this.validator.validate(events, ...callbacks) return this } @Basecoat.dispose() dispose() { this.validator.dispose() this.clean() this.stopListening() } protected 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)) } protected 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') } protected createCommand(options?: { batch: boolean }): HistoryManager.Command { return { batch: options ? options.batch : false, data: {} as HistoryManager.CreationData, } } protected revertCommand(cmd: HistoryManager.Commands, options?: KeyValue) { 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 = { ...options, ...ObjectExt.pick(cmd.options, this.options.revertOptionsList || []), } this.executeCommand(cmd, true, localOptions) } this.freezed = false } protected applyCommand(cmd: HistoryManager.Commands, options?: KeyValue) { 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 = { ...options, ...ObjectExt.pick(cmd.options, this.options.applyOptionsList || []), } this.executeCommand(cmd, false, localOptions) } this.freezed = false } protected executeCommand( cmd: HistoryManager.Command, revert: boolean, options: KeyValue, ) { 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.remove(options) } else if ( (Util.isAddEvent(event) && !revert) || (Util.isRemoveEvent(event) && revert) ) { const data = cmd.data as HistoryManager.CreationData 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 as HistoryManager.ChangingData const key = data.key if (key) { const value = revert ? data.prev[key] : data.next[key] cell.prop(key, value, options) } } else { const executeCommand = this.options.executeCommand if (executeCommand) { FunctionExt.call(executeCommand, this, cmd, revert, options) } } } protected addCommand<T extends keyof Model.EventArgs>( event: T, args: Model.EventArgs[T], ) { if (this.freezed || this.disabled) { return } const eventArgs = args as Model.EventArgs['cell:change:*'] 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}` as T } const cell = eventArgs.cell const isModelChange = Model.isModel(cell) let cmd: HistoryManager.Command 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 as HistoryManager.CreationData 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 as Model.EventArgs['cell:change:*']).key const data = cmd.data as HistoryManager.ChangingData 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 as string if (data.prev == null) { data.prev = {} } data.prev[key] = ObjectExt.clone(cell.previous(key)) if (isModelChange) { cmd.modelChange = true } else { data.id = cell.id } } if (data.next == null) { data.next = {} } data.next[key] = ObjectExt.clone(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 protected initBatchCommand(options: KeyValue) { 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. */ protected storeBatchCommand(options: KeyValue) { if (this.freezed) { return } if (this.batchCommands && this.batchLevel <= 0) { const cmds = this.filterBatchCommand(this.batchCommands) if (cmds.length > 0) { this.redoStack = [] this.undoStack.push(cmds) this.notify('add', cmds, options) } this.batchCommands = null this.lastBatchIndex = -1 this.batchLevel = 0 } else if (this.batchCommands && this.batchLevel > 0) { this.batchLevel -= 1 } } protected filterBatchCommand(batchCommands: HistoryManager.Command[]) { 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 as HistoryManager.ChangingData if (ObjectExt.isEqual(data.prev, data.next)) { continue } } else { // pass } result.push(cmd) } } return result } protected notify( event: keyof HistoryManager.EventArgs, cmd: HistoryManager.Commands | null, options: KeyValue, ) { const cmds = cmd == null ? null : Array.isArray(cmd) ? cmd : [cmd] this.emit(event, { cmds, options }) this.emit('change', { cmds, options }) } protected push(cmd: HistoryManager.Command, options: KeyValue) { this.redoStack = [] if (cmd.batch) { this.lastBatchIndex = Math.max(this.lastBatchIndex, 0) this.emit('batch', { cmd, options }) } else { this.undoStack.push(cmd) this.notify('add', cmd, options) } } } export namespace HistoryManager { export type ModelEvents = keyof Model.EventArgs export interface CommonOptions { enabled?: boolean ignoreAdd?: boolean ignoreRemove?: boolean ignoreChange?: boolean eventNames?: (keyof Model.EventArgs)[] /** * A function evaluated before any command is added. If the function * returns `false`, the command does not get stored. This way you can * control which commands do not get registered for undo/redo. */ beforeAddCommand?: <T extends ModelEvents>( this: HistoryManager, event: T, args: Model.EventArgs[T], ) => any afterAddCommand?: <T extends ModelEvents>( this: HistoryManager, event: T, args: Model.EventArgs[T], cmd: Command, ) => any executeCommand?: ( this: HistoryManager, cmd: Command, revert: boolean, options: KeyValue, ) => any /** * An array of options property names that passed in undo actions. */ revertOptionsList?: string[] /** * An array of options property names that passed in redo actions. */ applyOptionsList?: string[] /** * Determine whether to cancel an invalid command or not. */ cancelInvalid?: boolean } export interface Options extends Partial<CommonOptions> { graph: Graph } interface Data { id?: string } export interface CreationData extends Data { edge?: boolean node?: boolean props: Cell.Properties } export interface ChangingData extends Data { key: string prev: KeyValue next: KeyValue } export interface Command { batch: boolean modelChange?: boolean event?: ModelEvents data: CreationData | ChangingData options?: KeyValue } export type Commands = HistoryManager.Command[] | HistoryManager.Command } export namespace HistoryManager { interface Args<T = never> { cmds: Command[] | T options: KeyValue } export interface EventArgs extends Validator.EventArgs { /** * Triggered when a command was undone. */ undo: Args /** * Triggered when a command were redone. */ redo: Args /** * Triggered when a command was canceled. */ cancel: Args /** * Triggered when command(s) were added to the stack. */ add: Args /** * Triggered when all commands were clean. */ clean: Args<null> /** * Triggered when any change was made to stacks. */ change: Args<null> /** * Triggered when a batch command received. */ batch: { cmd: Command; options: KeyValue } } } export namespace HistoryManager { /** * 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. */ export class Validator extends Basecoat<Validator.EventArgs> { protected readonly command: HistoryManager protected readonly cancelInvalid: boolean protected readonly map: { [event: string]: Validator.Callback[][] } constructor(options: Validator.Options) { super() this.map = {} this.command = options.history this.cancelInvalid = options.cancelInvalid !== false this.command.on('add', this.onCommandAdded, this) } protected onCommandAdded({ cmds }: HistoryManager.EventArgs['add']) { return Array.isArray(cmds) ? cmds.every((cmd) => this.isValidCommand(cmd)) : this.isValidCommand(cmds) } protected isValidCommand(cmd: HistoryManager.Command) { if (cmd.options && cmd.options.validation === false) { return true } const callbacks = (cmd.event && this.map[cmd.event]) || [] let handoverErr: Error | null = null callbacks.forEach((routes) => { let i = 0 const rollup = (err: Error | null) => { 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: string | string[], ...callbacks: Validator.Callback[]) { 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 } @Basecoat.dispose() dispose() { this.command.off('add', this.onCommandAdded, this) } } export namespace Validator { export interface Options { history: HistoryManager /** * To cancel (= undo + delete from redo stack) a command if is not valid. */ cancelInvalid?: boolean } export type Callback = ( err: Error | null, cmd: HistoryManager.Command, next: (err: Error | null) => any, ) => any export interface EventArgs { invalid: { err: Error } } } } namespace Util { export function isAddEvent(event?: HistoryManager.ModelEvents) { return event === 'cell:added' } export function isRemoveEvent(event?: HistoryManager.ModelEvents) { return event === 'cell:removed' } export function isChangeEvent(event?: HistoryManager.ModelEvents) { return event != null && event.startsWith('cell:change:') } export function getOptions( options: HistoryManager.Options, ): HistoryManager.CommonOptions { const { graph, ...others } = options const reservedNames: HistoryManager.ModelEvents[] = [ 'cell:added', 'cell:removed', 'cell:change:*', ] const batchEvents: HistoryManager.ModelEvents[] = [ 'batch:start', 'batch:stop', ] const eventNames = options.eventNames ? options.eventNames.filter( (event) => !( Util.isChangeEvent(event) || reservedNames.includes(event) || batchEvents.includes(event) ), ) : reservedNames return { ...others, eventNames, applyOptionsList: options.applyOptionsList || ['propertyPath'], revertOptionsList: options.revertOptionsList || ['propertyPath'], } } export function sortBatchCommands(cmds: HistoryManager.Command[]) { const results: HistoryManager.Command[] = [] for (let i = 0, ii = cmds.length; i < ii; i += 1) { const cmd = cmds[i] let index: number | null = 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 } }