@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
778 lines (679 loc) • 20.6 kB
text/typescript
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
}
.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
}
.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
}
}