@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
656 lines (579 loc) • 17.5 kB
text/typescript
import {
Basecoat,
disposable,
FunctionExt,
type KeyValue,
ObjectExt,
} from '../../common'
import type { Graph, GraphPlugin } from '../../graph'
import { Model, type ModelEventArgs } from '../../model'
import type {
HistoryChangingData,
HistoryCommand,
HistoryCommands,
HistoryCommonOptions,
HistoryCreationData,
HistoryEventArgs,
HistoryModelEvents,
HistoryOptions,
} from './type'
import {
getOptions,
isAddEvent,
isChangeEvent,
isRemoveEvent,
sortBatchCommands,
} from './util'
import { Validator, type ValidatorCallback } from './validator'
import './api'
export class History extends Basecoat<HistoryEventArgs> implements GraphPlugin {
public name = 'history'
public graph: Graph
public model: Model
public readonly options: HistoryCommonOptions
public readonly validator: Validator
protected redoStack: HistoryCommands[]
protected undoStack: HistoryCommands[]
protected batchCommands: HistoryCommand[] | null = null
protected batchLevel = 0
protected lastBatchIndex = -1
protected freezed = false
protected stackSize = 0 // 0: not limit
protected readonly handlers: (<T extends HistoryModelEvents>(
event: T,
args: ModelEventArgs[T],
) => any)[] = []
constructor(options: HistoryOptions = {}) {
super()
const { stackSize = 0 } = options
this.stackSize = stackSize
this.options = getOptions(options)
this.validator = new Validator({
history: this,
cancelInvalid: this.options.cancelInvalid,
})
}
init(graph: 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?: boolean) {
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: 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.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: KeyValue = {}) {
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: KeyValue = {}) {
this.undoStack = []
this.redoStack = []
this.notify('clean', null, options)
return this
}
// #endregion
get disabled() {
return this.options.enabled !== true
}
protected validate(
events: string | string[],
...callbacks: ValidatorCallback[]
) {
this.validator.validate(events, ...callbacks)
return this
}
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 }): HistoryCommand {
return {
batch: options ? options.batch : false,
data: {} as HistoryCreationData,
}
}
protected revertCommand(cmd: HistoryCommands, options?: KeyValue) {
this.freezed = true
const cmds = Array.isArray(cmd) ? 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: HistoryCommands, options?: KeyValue) {
this.freezed = true
const cmds = Array.isArray(cmd) ? 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: HistoryCommand,
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 ((isAddEvent(event) && revert) || (isRemoveEvent(event) && !revert)) {
cell && cell.remove(options)
} else if (
(isAddEvent(event) && !revert) ||
(isRemoveEvent(event) && revert)
) {
const data = cmd.data as HistoryCreationData
if (data.node) {
model.addNode(data.props, options)
} else if (data.edge) {
model.addEdge(data.props, options)
}
} else if (isChangeEvent(event)) {
const data = cmd.data as HistoryChangingData
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)
}
}
}
protected addCommand<T extends keyof ModelEventArgs>(
event: T,
args: ModelEventArgs[T],
) {
if (this.freezed || this.disabled) {
return
}
const eventArgs = args as ModelEventArgs['cell:change:*']
const options = eventArgs.options || {}
if (options.dryrun) {
return
}
if (
(isAddEvent(event) && this.options.ignoreAdd) ||
(isRemoveEvent(event) && this.options.ignoreRemove) ||
(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: HistoryCommand
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 || isAddEvent(event) || 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 (isAddEvent(event) || isRemoveEvent(event)) {
const data = cmd.data as HistoryCreationData
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 (isChangeEvent(event)) {
const key = (args as ModelEventArgs['cell:change:*']).key
const data = cmd.data as HistoryChangingData
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.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
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.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
}
}
protected filterBatchCommand(batchCommands: HistoryCommand[]) {
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 (isAddEvent(evt)) {
const index = cmds.findIndex(
(c) => isRemoveEvent(c.event) && c.data.id === id,
)
if (index >= 0) {
cmds = cmds.filter((c, i) => index < i || c.data.id !== id)
continue
}
} else if (isRemoveEvent(evt)) {
const index = cmds.findIndex(
(c) => isAddEvent(c.event) && c.data.id === id,
)
if (index >= 0) {
cmds.splice(index, 1)
continue
}
} else if (isChangeEvent(evt)) {
const data = cmd.data as HistoryChangingData
if (ObjectExt.isEqual(data.prev, data.next)) {
continue
}
} else {
// pass
}
result.push(cmd)
}
}
return result
}
protected notify(
event: keyof HistoryEventArgs,
cmd: HistoryCommands | null,
options: KeyValue,
) {
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 })
}
protected push(cmd: HistoryCommand, options: KeyValue) {
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.
*/
protected consolidateCommands() {
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) => cmd.batch && cmd.options?.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' ||
!maybePositionChange.options?.ui
) {
return
}
// Actually consolidating the commands we get
penultimateCommandGroup.push(...lastCommandGroup)
this.undoStack.pop()
}
protected undoStackPush(cmd: HistoryCommands) {
if (this.stackSize === 0) {
this.undoStack.push(cmd)
return
}
if (this.undoStack.length >= this.stackSize) {
this.undoStack.shift()
}
this.undoStack.push(cmd)
}
protected ensureUndefinedAttrs(
newAttrs: Record<string, any>,
oldAttrs: Record<string, any>,
) {
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()
}
}