UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering

1,892 lines (1,602 loc) 45.9 kB
/* eslint-disable no-underscore-dangle */ /** biome-ignore-all lint/complexity/noThisInStatic: <存量的问题biome修了运行的实际效果就变了,所以先忽略> */ import type { NonUndefined } from 'utility-types' import { ArrayExt, Basecoat, disposable, FunctionExt, type KeyValue, NumberExt, ObjectExt, type Size, StringExt, } from '../common' import { Point, type PointLike, Rectangle } from '../geometry' import type { Graph } from '../graph' import { type AttrDefinitions, attrRegistry, type CellAttrs, type ComplexAttrValue, } from '../registry' import type { CellView } from '../view' import type { MarkupType } from '../view/markup' import { Animation, AnimationManager, type AnimationPlaybackEvent, KeyframeEffect, type KeyframeEffectOptions, } from './animation' import type { ConnectorData, Edge, EdgeLabel, EdgeProperties, RouterData, TerminalData, TerminalType, } from './edge' import type { BatchName, Model } from './model' import type { Node, NodeProperties, NodeSetOptions } from './node' import type { Port } from './port' import type { StoreMutateOptions, StoreSetByPathOptions, StoreSetOptions, } from './store' import { Store } from './store' export class Cell< Properties extends CellProperties = CellProperties, > extends Basecoat<CellBaseEventArgs> { static toStringTag = `X6.cell` static isCell(instance: any): instance is Cell { if (instance == null) { return false } if (instance instanceof Cell) { return true } const tag = instance[Symbol.toStringTag] const cell = instance as Cell if ( (tag == null || tag === Cell.toStringTag) && typeof cell.isNode === 'function' && typeof cell.isEdge === 'function' && typeof cell.prop === 'function' && typeof cell.attr === 'function' ) { return true } return false } static normalizeTools(raw: ToolsLoose): Tools { if (typeof raw === 'string') { return { items: [raw] } } if (Array.isArray(raw)) { return { items: raw } } if ((raw as Tools).items) { return raw as Tools } if (Reflect.has(raw, 'local')) { const { local, ...resetItem } = raw as Tools return { local, items: [resetItem as ToolItem], } } return { items: [raw as ToolItem], } } static getCommonAncestor(...cells: (Cell | null | undefined)[]): Cell | null { const ancestors = cells .filter((cell) => cell != null) .map((cell) => cell!.getAncestors()) .sort((a, b) => { return a.length - b.length }) const first = ancestors.shift()! return ( first.find((cell) => ancestors.every((item) => item.includes(cell))) || null ) } static getCellsBBox(cells: Cell[], options: CellGetCellsBBoxOptions = {}) { let bbox: Rectangle | null = null for (let i = 0, ii = cells.length; i < ii; i += 1) { const cell = cells[i] let rect = cell.getBBox(options) if (rect) { if (cell.isNode()) { const angle = cell.getAngle() if (angle != null && angle !== 0) { rect = rect.bbox(angle) } } bbox = bbox == null ? rect : bbox.union(rect) } } return bbox } static deepClone(cell: Cell) { const cells = [cell, ...cell.getDescendants({ deep: true })] return Cell.cloneCells(cells) } static cloneCells(cells: Cell[]) { const inputs = ArrayExt.uniq(cells) const cloneMap = inputs.reduce<KeyValue<Cell>>( (map: KeyValue<Cell>, cell: Cell) => { map[cell.id] = cell.clone() return map }, {}, ) inputs.forEach((cell: Cell) => { const clone = cloneMap[cell.id] if (clone.isEdge()) { const sourceId = clone.getSourceCellId() const targetId = clone.getTargetCellId() if (sourceId && cloneMap[sourceId]) { // Source is a node and the node is among the clones. // Then update the source of the cloned edge. clone.setSource({ ...clone.getSource(), cell: cloneMap[sourceId].id, }) } if (targetId && cloneMap[targetId]) { // Target is a node and the node is among the clones. // Then update the target of the cloned edge. clone.setTarget({ ...clone.getTarget(), cell: cloneMap[targetId].id, }) } } // Find the parent of the original cell const parent = cell.getParent() if (parent && cloneMap[parent.id]) { clone.setParent(cloneMap[parent.id]) } // Find the children of the original cell const children = cell.getChildren() if (children && children.length) { const embeds = children.reduce<Cell[]>((memo: Cell[], child: Cell) => { // Embedded cells that are not being cloned can not be carried // over with other embedded cells. if (cloneMap[child.id]) { memo.push(cloneMap[child.id]) } return memo }, []) if (embeds.length > 0) { clone.setChildren(embeds) } } }) return cloneMap } // #region static protected static markup: MarkupType protected static defaults: CellDefaults = {} protected static attrHooks: AttrDefinitions = {} protected static propHooks: CellPropHook[] = [] public static config<C extends CellConfig = CellConfig>(presets: C) { const { markup, propHooks, attrHooks, ...others } = presets if (markup != null) { this.markup = markup } if (propHooks) { this.propHooks = this.propHooks.slice() if (Array.isArray(propHooks)) { this.propHooks.push(...propHooks) } else if (typeof propHooks === 'function') { this.propHooks.push(propHooks) } else { Object.values(propHooks).forEach((hook) => { if (typeof hook === 'function') { this.propHooks.push(hook) } }) } } if (attrHooks) { this.attrHooks = { ...this.attrHooks, ...attrHooks } } this.defaults = ObjectExt.merge({}, this.defaults, others) } public static getMarkup() { return this.markup } public static getDefaults<T extends CellDefaults = CellDefaults>( raw?: boolean, ): T { return (raw ? this.defaults : ObjectExt.cloneDeep(this.defaults)) as T } public static getAttrHooks() { return this.attrHooks } public static applyPropHooks( cell: Cell, metadata: CellMetadata, ): CellMetadata { return this.propHooks.reduce((memo, hook) => { return hook ? FunctionExt.call(hook, cell, memo) : memo }, metadata) } // eslint-disable-next-line public static generateId(metadata: CellMetadata = {}) { return StringExt.uuid() } // #endregion protected get [Symbol.toStringTag]() { return Cell.toStringTag } public readonly id: string protected readonly store: Store<CellProperties> protected readonly animationManager: AnimationManager protected _model: Model | null // eslint-disable-line protected _parent: Cell | null // eslint-disable-line protected _children: Cell[] | null // eslint-disable-line constructor(metadata: CellMetadata = {}) { super() const ctor = this.constructor as typeof Cell const defaults = ctor.getDefaults(true) const props = ObjectExt.merge( {}, this.preprocess(defaults), this.preprocess(metadata), ) this.id = props.id || Cell.generateId(metadata) this.store = new Store(props) this.animationManager = new AnimationManager() this.setup() this.init() this.postprocess(metadata) } init() {} // #region model get model() { return this._model } set model(model: Model | null) { if (this._model !== model) { this._model = model } } // #endregion protected preprocess( metadata: CellMetadata, ignoreIdCheck?: boolean, ): Properties { const id = metadata.id const ctor = this.constructor as typeof Cell const props = ctor.applyPropHooks(this, metadata) if (id == null && ignoreIdCheck !== true) { props.id = Cell.generateId(metadata) } return props as Properties } protected postprocess(metadata: CellMetadata) {} // eslint-disable-line protected setup() { this.store.on('change:*', (metadata) => { const { key, current, previous, options } = metadata this.notify('change:*', { key, options, current, previous, cell: this, }) this.notify(`change:${key}` as keyof CellBaseEventArgs, { options, current, previous, cell: this, }) const type = key as TerminalType if (type === 'source' || type === 'target') { this.notify(`change:terminal`, { type, current, previous, options, cell: this, }) } }) this.store.on('changed', ({ options }) => this.notify('changed', { options, cell: this }), ) this.on('added', ({ cell }) => { const animation = this.store.get('animation') if (!ObjectExt.isEmpty(animation)) { animation.forEach((p) => { cell.animate(...p) }) } }) } notify<Key extends keyof CellBaseEventArgs>( name: Key, args: CellBaseEventArgs[Key], ): this notify(name: Exclude<string, keyof CellBaseEventArgs>, args: any): this notify<Key extends keyof CellBaseEventArgs>( name: Key, args: CellBaseEventArgs[Key], ) { this.trigger(name, args) const model = this.model if (model) { model.notify(`cell:${name}`, args) if (this.isNode()) { model.notify(`node:${name}`, { ...args, node: this }) } else if (this.isEdge()) { model.notify(`edge:${name}`, { ...args, edge: this }) } } return this } isNode(): this is Node { return false } isEdge(): this is Edge { return false } isSameStore(cell: Cell) { return this.store === cell.store } get view() { return this.store.get('view') } get shape() { return this.store.get('shape', '') } // #region get/set getProp(): Properties getProp<K extends keyof Properties>(key: K): Properties[K] getProp<K extends keyof Properties>( key: K, defaultValue: Properties[K], ): NonUndefined<Properties[K]> getProp<T>(key: string): T getProp<T>(key: string, defaultValue: T): T getProp(key?: string, defaultValue?: any) { if (key == null) { return this.store.get() } return this.store.get(key, defaultValue) } setProp<K extends keyof Properties>( key: K, value: Properties[K] | null | undefined | void, options?: CellSetOptions, ): this setProp(key: string, value: any, options?: CellSetOptions): this setProp(props: Partial<Properties>, options?: CellSetOptions): this setProp( key: string | Partial<Properties>, value?: any, options?: CellSetOptions, ) { if (typeof key === 'string') { this.store.set(key, value, options) } else { const props = this.preprocess(key, true) this.store.set(ObjectExt.merge({}, this.getProp(), props), value) this.postprocess(key) } return this } removeProp<K extends keyof Properties>( key: K | K[], options?: CellSetOptions, ): this removeProp(key: string | string[], options?: CellSetOptions): this removeProp(options?: CellSetOptions): this removeProp( key?: string | string[] | CellSetOptions, options?: CellSetOptions, ) { if (typeof key === 'string' || Array.isArray(key)) { this.store.removeByPath(key, options) } else { this.store.remove(options) } return this } hasChanged(): boolean hasChanged<K extends keyof Properties>(key: K | null): boolean hasChanged(key: string | null): boolean hasChanged(key?: string | null) { return key == null ? this.store.hasChanged() : this.store.hasChanged(key) } getPropByPath<T>(path: string | string[]) { return this.store.getByPath<T>(path) } setPropByPath( path: string | string[], value: any, options: SetByPathOptions = {}, ) { if (this.model) { // update inner reference if (path === 'children') { this._children = value ? value .map((id: string) => this.model!.getCell(id)) .filter((child: Cell) => child != null) : null } else if (path === 'parent') { this._parent = value ? this.model.getCell(value) : null } } this.store.setByPath(path, value, options) return this } removePropByPath(path: string | string[], options: CellSetOptions = {}) { const paths = Array.isArray(path) ? path : path.split('/') // Once a property is removed from the `attrs` the CellView will // recognize a `dirty` flag and re-render itself in order to remove // the attribute from SVGElement. if (paths[0] === 'attrs') { options.dirty = true } this.store.removeByPath(paths, options) return this } prop(): Properties prop<K extends keyof Properties>(key: K): Properties[K] prop<T>(key: string): T prop<T>(path: string[]): T prop<K extends keyof Properties>( key: K, value: Properties[K] | null | undefined | void, options?: CellSetOptions, ): this prop(key: string, value: any, options?: CellSetOptions): this prop(path: string[], value: any, options?: CellSetOptions): this prop(props: Partial<Properties>, options?: CellSetOptions): this prop( key?: string | string[] | Partial<Properties>, value?: any, options?: CellSetOptions, ) { if (key == null) { return this.getProp() } if (typeof key === 'string' || Array.isArray(key)) { if (arguments.length === 1) { return this.getPropByPath(key) } if (value == null) { return this.removePropByPath(key, options || {}) } return this.setPropByPath(key, value, options || {}) } return this.setProp(key, value || {}) } previous<K extends keyof Properties>(name: K): Properties[K] | undefined previous<T>(name: string): T | undefined previous(name: string) { return this.store.getPrevious(name as keyof CellProperties) } // #endregion // #region zIndex get zIndex() { return this.getZIndex() } set zIndex(z: number | undefined | null) { if (z == null) { this.removeZIndex() } else { this.setZIndex(z) } } getZIndex() { return this.store.get('zIndex') } setZIndex(z: number, options: CellSetOptions = {}) { this.store.set('zIndex', z, options) return this } removeZIndex(options: CellSetOptions = {}) { this.store.remove('zIndex', options) return this } toFront(options: ToFrontOptions = {}) { const model = this.model if (model) { let z = model.getMaxZIndex() let cells: Cell[] if (options.deep) { cells = this.getDescendants({ deep: true, breadthFirst: true }) cells.unshift(this) } else { cells = [this] } z = z - cells.length + 1 const count = model.total() let changed = model.indexOf(this) !== count - cells.length if (!changed) { changed = cells.some((cell, index) => cell.getZIndex() !== z + index) } if (changed) { this.batchUpdate('to-front', () => { z += cells.length cells.forEach((cell, index) => { cell.setZIndex(z + index, options) }) }) } } return this } toBack(options: ToBackOptions = {}) { const model = this.model if (model) { let z = model.getMinZIndex() let cells: Cell[] if (options.deep) { cells = this.getDescendants({ deep: true, breadthFirst: true }) cells.unshift(this) } else { cells = [this] } let changed = model.indexOf(this) !== 0 if (!changed) { changed = cells.some((cell, index) => cell.getZIndex() !== z + index) } if (changed) { this.batchUpdate('to-back', () => { z -= cells.length cells.forEach((cell, index) => { cell.setZIndex(z + index, options) }) }) } } return this } // #endregion // #region markup get markup() { return this.getMarkup() } set markup(value: MarkupType | undefined | null) { if (value == null) { this.removeMarkup() } else { this.setMarkup(value) } } getMarkup() { let markup = this.store.get('markup') if (markup == null) { const ctor = this.constructor as typeof Cell markup = ctor.getMarkup() } return markup } setMarkup(markup: MarkupType, options: CellSetOptions = {}) { this.store.set('markup', markup, options) return this } removeMarkup(options: CellSetOptions = {}) { this.store.remove('markup', options) return this } // #endregion // #region attrs get attrs() { return this.getAttrs() } set attrs(value: CellAttrs | null | undefined) { if (value == null) { this.removeAttrs() } else { this.setAttrs(value) } } getAttrs() { const result = this.store.get('attrs') return result ? { ...result } : {} } setAttrs(attrs: CellAttrs | null | undefined, options: SetAttrOptions = {}) { if (attrs == null) { this.removeAttrs(options) } else { const set = (attrs: CellAttrs) => this.store.set('attrs', attrs, options) if (options.overwrite === true) { set(attrs) } else { const prev = this.getAttrs() if (options.deep === false) { set({ ...prev, ...attrs }) } else { set(ObjectExt.merge({}, prev, attrs)) } } } return this } replaceAttrs(attrs: CellAttrs, options: CellSetOptions = {}) { return this.setAttrs(attrs, { ...options, overwrite: true }) } updateAttrs(attrs: CellAttrs, options: CellSetOptions = {}) { return this.setAttrs(attrs, { ...options, deep: false }) } removeAttrs(options: CellSetOptions = {}) { this.store.remove('attrs', options) return this } getAttrDefinition(attrName: string) { if (!attrName) { return null } const ctor = this.constructor as typeof Cell const hooks = ctor.getAttrHooks() || {} let definition = hooks[attrName] || attrRegistry.get(attrName) if (!definition) { const name = StringExt.camelCase(attrName) definition = hooks[name] || attrRegistry.get(name) } return definition || null } getAttrByPath(): CellAttrs getAttrByPath<T>(path: string | string[]): T getAttrByPath<T>(path?: string | string[]) { if (path == null || path === '') { return this.getAttrs() } return this.getPropByPath<T>(this.prefixAttrPath(path)) } setAttrByPath( path: string | string[], value: ComplexAttrValue, options: CellSetOptions = {}, ) { this.setPropByPath(this.prefixAttrPath(path), value, options) return this } removeAttrByPath(path: string | string[], options: CellSetOptions = {}) { this.removePropByPath(this.prefixAttrPath(path), options) return this } protected prefixAttrPath(path: string | string[]) { return Array.isArray(path) ? ['attrs'].concat(path) : `attrs/${path}` } attr(): CellAttrs attr<T>(path: string | string[]): T attr( path: string | string[], value: ComplexAttrValue | null, options?: CellSetOptions, ): this attr(attrs: CellAttrs, options?: SetAttrOptions): this attr( path?: string | string[] | CellAttrs, value?: ComplexAttrValue | CellSetOptions, options?: CellSetOptions, ) { if (path == null) { return this.getAttrByPath() } if (typeof path === 'string' || Array.isArray(path)) { if (arguments.length === 1) { return this.getAttrByPath(path) } if (value == null) { return this.removeAttrByPath(path, options || {}) } return this.setAttrByPath(path, value as ComplexAttrValue, options || {}) } return this.setAttrs(path, (value || {}) as CellSetOptions) } // #endregion // #region visible get visible() { return this.isVisible() } set visible(value: boolean) { this.setVisible(value) } setVisible(visible: boolean, options: CellSetOptions = {}) { this.store.set('visible', visible, options) return this } isVisible() { return this.store.get('visible') !== false } show(options: CellSetOptions = {}) { if (!this.isVisible()) { this.setVisible(true, options) } return this } hide(options: CellSetOptions = {}) { if (this.isVisible()) { this.setVisible(false, options) } return this } toggleVisible(visible: boolean, options?: CellSetOptions): this toggleVisible(options?: CellSetOptions): this toggleVisible( isVisible?: boolean | CellSetOptions, options: CellSetOptions = {}, ) { const visible = typeof isVisible === 'boolean' ? isVisible : !this.isVisible() const localOptions = typeof isVisible === 'boolean' ? options : isVisible if (visible) { this.show(localOptions) } else { this.hide(localOptions) } return this } // #endregion // #region data get data(): Properties['data'] { return this.getData() } set data(val: Properties['data']) { this.setData(val) } getData<T = Properties['data']>(): T { return this.store.get<T>('data') } setData<T = Properties['data']>(data: T, options: SetDataOptions = {}) { if (data == null) { this.removeData(options) } else { const set = (data: T) => this.store.set('data', data, options) if (options.overwrite === true) { set(data) } else { const prev = this.getData<Record<string, any>>() if (options.deep === false) { set(typeof data === 'object' ? { ...prev, ...data } : data) } else { set(ObjectExt.merge({}, prev, data)) } } } return this } replaceData<T = Properties['data']>(data: T, options: CellSetOptions = {}) { return this.setData(data, { ...options, overwrite: true }) } updateData<T = Properties['data']>(data: T, options: CellSetOptions = {}) { return this.setData(data, { ...options, deep: false }) } removeData(options: CellSetOptions = {}) { this.store.remove('data', options) return this } // #endregion // #region parent children get parent(): Cell | null { return this.getParent() } get children() { return this.getChildren() } getParentId() { return this.store.get('parent') } getParent<T extends Cell = Cell>(): T | null { const parentId = this.getParentId() if (parentId && this.model) { const parent = this.model.getCell<T>(parentId) this._parent = parent return parent } return null } getChildren() { const childrenIds = this.store.get('children') if (childrenIds && childrenIds.length && this.model) { const children = childrenIds .map((id) => this.model?.getCell(id)) .filter((cell) => cell != null) as Cell[] this._children = children return [...children] } return null } hasParent() { return this.parent != null } isParentOf(child: Cell | null): boolean { return child != null && child.getParent() === this } isChildOf(parent: Cell | null): boolean { return parent != null && this.getParent() === parent } eachChild( iterator: (child: Cell, index: number, children: Cell[]) => void, context?: any, ) { if (this.children) { this.children.forEach(iterator, context) } return this } filterChild( filter: (cell: Cell, index: number, arr: Cell[]) => boolean, context?: any, ): Cell[] { return this.children ? this.children.filter(filter, context) : [] } getChildCount() { return this.children == null ? 0 : this.children.length } getChildIndex(child: Cell) { return this.children == null ? -1 : this.children.indexOf(child) } getChildAt(index: number) { return this.children != null && index >= 0 ? this.children[index] : null } getAncestors(options: { deep?: boolean } = {}): Cell[] { const ancestors: Cell[] = [] let parent = this.getParent() while (parent) { ancestors.push(parent) parent = options.deep !== false ? parent.getParent() : null } return ancestors } getDescendants(options: CellGetDescendantsOptions = {}): Cell[] { if (options.deep !== false) { // breadth first if (options.breadthFirst) { const cells = [] const queue = this.getChildren() || [] while (queue.length > 0) { const parent = queue.shift()! const children = parent.getChildren() cells.push(parent) if (children) { queue.push(...children) } } return cells } // depth first { const cells = this.getChildren() || [] cells.forEach((cell) => { cells.push(...cell.getDescendants(options)) }) return cells } } return this.getChildren() || [] } isDescendantOf( ancestor: Cell | null, options: { deep?: boolean } = {}, ): boolean { if (ancestor == null) { return false } if (options.deep !== false) { let current = this.getParent() while (current) { if (current === ancestor) { return true } current = current.getParent() } return false } return this.isChildOf(ancestor) } isAncestorOf( descendant: Cell | null, options: { deep?: boolean } = {}, ): boolean { if (descendant == null) { return false } return descendant.isDescendantOf(this, options) } contains(cell: Cell | null) { return this.isAncestorOf(cell) } getCommonAncestor(...cells: (Cell | null | undefined)[]): Cell | null { return Cell.getCommonAncestor(this, ...cells) } setParent(parent: Cell | null, options: CellSetOptions = {}) { this._parent = parent if (parent) { this.store.set('parent', parent.id, options) } else { this.store.remove('parent', options) } return this } setChildren(children: Cell[] | null, options: CellSetOptions = {}) { this._children = children if (children != null) { this.store.set( 'children', children.map((child) => child.id), options, ) } else { this.store.remove('children', options) } return this } unembed(child: Cell, options: CellSetOptions = {}) { const children = this.children if (children != null && child != null) { const index = this.getChildIndex(child) if (index !== -1) { children.splice(index, 1) child.setParent(null, options) this.setChildren(children, options) } } return this } embed(child: Cell, options: CellSetOptions = {}) { child.addTo(this, options) return this } addTo(model: Model, options?: CellSetOptions): this addTo(graph: Graph, options?: CellSetOptions): this addTo(parent: Cell, options?: CellSetOptions): this addTo(target: Model | Graph | Cell, options: CellSetOptions = {}) { if (Cell.isCell(target)) { target.addChild(this, options) } else { target.addCell(this, options) } return this } insertTo(parent: Cell, index?: number, options: CellSetOptions = {}) { parent.insertChild(this, index, options) return this } addChild(child: Cell | null, options: CellSetOptions = {}) { return this.insertChild(child, undefined, options) } insertChild( child: Cell | null, index?: number, options: CellSetOptions = {}, ): this { if (child != null && child !== this) { const oldParent = child.getParent() const changed = this !== oldParent let pos = index if (pos == null) { pos = this.getChildCount() if (!changed) { pos -= 1 } } // remove from old parent if (oldParent) { const children = oldParent.getChildren() if (children) { const index = children.indexOf(child) if (index >= 0) { child.setParent(null, options) children.splice(index, 1) oldParent.setChildren(children, options) } } } let children = this.children if (children == null) { children = [] children.push(child) } else { children.splice(pos, 0, child) } child.setParent(this, options) this.setChildren(children, options) if (changed && this.model) { const incomings = this.model.getIncomingEdges(this) const outgoings = this.model.getOutgoingEdges(this) if (incomings) { incomings.forEach((edge) => { edge.updateParent(options) }) } if (outgoings) { outgoings.forEach((edge) => { edge.updateParent(options) }) } } if (this.model) { this.model.addCell(child, options) } } return this } removeFromParent(options: CellRemoveOptions = {}) { const parent = this.getParent() if (parent != null) { const index = parent.getChildIndex(this) parent.removeChildAt(index, options) } return this } removeChild(child: Cell, options: CellRemoveOptions = {}) { const index = this.getChildIndex(child) return this.removeChildAt(index, options) } removeChildAt(index: number, options: CellRemoveOptions = {}) { const child = this.getChildAt(index) const children = this.children if (children != null && child != null) { this.unembed(child, options) child.remove(options) } return child } remove(options: CellRemoveOptions = {}) { this.batchUpdate('remove', () => { const parentId = this.getParentId() const parent = parentId && this.model ? this.model.getCell(parentId) : this._parent if (parent) { const childrenIds = parent.store.get('children') as string[] | undefined if (childrenIds && childrenIds.length) { const nextChildrenIds = childrenIds.filter((id) => id !== this.id) if (nextChildrenIds.length !== childrenIds.length) { if (nextChildrenIds.length) { parent.store.set('children', nextChildrenIds, options) } else { parent.store.remove('children', options) } } } } this.setParent(null, options) if (options.deep !== false) { this.eachChild((child) => child.remove(options)) } if (this.model) { this.model.removeCell(this, options) } this.dispose() }) return this } // #endregion // #region animation animate( keyframes: Keyframe[] | PropertyIndexedKeyframes | null, options?: number | KeyframeAnimationOptions, ) { const optionsObj = NumberExt.isNumber(options) ? { duration: options } : { ...options } const effect = new KeyframeEffect(this, keyframes, optionsObj) const animation = new Animation(effect, optionsObj.timeline) this.animationManager.addAnimation(animation) animation.id = optionsObj.id ?? '' animation.play() return animation } getAnimations() { return this.animationManager.getAnimations() } // #endregion // #region transform // eslint-disable-next-line translate(tx: number, ty: number, options?: CellTranslateOptions) { return this } scale( sx: number, // eslint-disable-line sy: number, // eslint-disable-line origin?: Point | PointLike, // eslint-disable-line options?: NodeSetOptions, // eslint-disable-line ) { return this } // #endregion // #region tools addTools(items: ToolItem | ToolItem[], options?: AddToolOptions): void addTools( items: ToolItem | ToolItem[], name: string, options?: AddToolOptions, ): void addTools( items: ToolItem | ToolItem[], obj?: string | AddToolOptions, options?: AddToolOptions, ) { const toolItems = Array.isArray(items) ? items : [items] const name = typeof obj === 'string' ? obj : null const config = typeof obj === 'object' ? obj : typeof options === 'object' ? options : {} if (config.reset) { return this.setTools( { name, items: toolItems, local: config.local }, config, ) } let tools = ObjectExt.cloneDeep(this.getTools()) if (tools == null || name == null || tools.name === name) { if (tools == null) { tools = {} as Tools } if (!tools.items) { tools.items = [] } tools.name = name tools.items = [...tools.items, ...toolItems] return this.setTools({ ...tools }, config) } } setTools(tools?: ToolsLoose | null, options: CellSetOptions = {}) { if (tools == null) { this.removeTools() } else { this.store.set('tools', Cell.normalizeTools(tools), options) } return this } getTools(): Tools | null { return this.store.get<Tools>('tools') } removeTools(options: CellSetOptions = {}) { this.store.remove('tools', options) return this } hasTools(name?: string) { const tools = this.getTools() if (tools == null) { return false } if (name == null) { return true } return tools.name === name } hasTool(name: string) { const tools = this.getTools() if (tools == null) { return false } return tools.items.some((item) => typeof item === 'string' ? item === name : item.name === name, ) } removeTool(name: string, options?: CellSetOptions): this removeTool(index: number, options?: CellSetOptions): this removeTool(nameOrIndex: string | number, options: CellSetOptions = {}) { const tools = ObjectExt.cloneDeep(this.getTools()) if (tools) { let updated = false const items = tools.items.slice() const remove = (index: number) => { items.splice(index, 1) updated = true } if (typeof nameOrIndex === 'number') { remove(nameOrIndex) } else { for (let i = items.length - 1; i >= 0; i -= 1) { const item = items[i] const exist = typeof item === 'string' ? item === nameOrIndex : item.name === nameOrIndex if (exist) { remove(i) } } } if (updated) { tools.items = items this.setTools(tools, options) } } return this } // #endregion // #region common // eslint-disable-next-line getBBox(options?: { deep?: boolean }) { return new Rectangle() } // eslint-disable-next-line getConnectionPoint(edge: Edge, type: TerminalType) { return new Point() } toJSON( options: CellToJSONOptions = {}, ): this extends Node ? NodeProperties : this extends Edge ? EdgeProperties : Properties { const props = { ...this.store.get() } const objectToString = Object.prototype.toString const cellType = this.isNode() ? 'node' : this.isEdge() ? 'edge' : 'cell' if (!props.shape) { const ctor = this.constructor throw new Error( `Unable to serialize ${cellType} missing "shape" prop, check the ${cellType} "${ ctor.name || objectToString.call(ctor) }"`, ) } const ctor = this.constructor as typeof Cell const diff = options.diff === true const attrs = props.attrs || {} const presets = ctor.getDefaults(true) as Properties // When `options.diff` is `true`, we should process the custom options, // such as `width`, `height` etc. to ensure the comparing work correctly. const defaults = diff ? this.preprocess(presets, true) : presets const defaultAttrs = defaults.attrs || {} const finalAttrs: CellAttrs = {} Object.entries(props).forEach(([key, val]) => { if ( val != null && !Array.isArray(val) && typeof val === 'object' && !ObjectExt.isPlainObject(val) ) { throw new Error( `Can only serialize ${cellType} with plain-object props, but got a "${toString.call( val, )}" type of key "${key}" on ${cellType} "${this.id}"`, ) } if (key !== 'attrs' && key !== 'shape' && diff) { const preset = defaults[key] if (ObjectExt.isEqual(val, preset)) { delete props[key] } } }) Object.keys(attrs).forEach((key) => { const attr = attrs[key] const defaultAttr = defaultAttrs[key] Object.keys(attr).forEach((name) => { const value = attr[name] as KeyValue const defaultValue = defaultAttr ? defaultAttr[name] : null if ( value != null && typeof value === 'object' && !Array.isArray(value) ) { Object.keys(value).forEach((subName) => { const subValue = value[subName] if ( defaultAttr == null || defaultValue == null || !ObjectExt.isObject(defaultValue) || !ObjectExt.isEqual(defaultValue[subName], subValue) ) { if (finalAttrs[key] == null) { finalAttrs[key] = {} } if (finalAttrs[key][name] == null) { finalAttrs[key][name] = {} } const tmp = finalAttrs[key][name] as KeyValue tmp[subName] = subValue } }) } else if ( defaultAttr == null || !ObjectExt.isEqual(defaultValue, value) ) { // `value` is not an object, default attribute with `key` does not // exist or it is different than the attribute value set on the cell. if (finalAttrs[key] == null) { finalAttrs[key] = {} } finalAttrs[key][name] = value as any } }) }) const finalProps = { ...props, attrs: ObjectExt.isEmpty(finalAttrs) ? undefined : finalAttrs, } if (finalProps.attrs == null) { delete finalProps.attrs } const ret = finalProps as any if (ret.angle === 0) { delete ret.angle } return ObjectExt.cloneDeep(ret) } clone( options: CloneOptions = {}, ): this extends Node ? Node : this extends Edge ? Edge : Cell { if (!options.deep) { const data = { ...this.store.get() } if (!options.keepId) { delete data.id } delete data.parent delete data.children const ctor = this.constructor as typeof Cell return new ctor(data) as any // eslint-disable-line new-cap } // Deep cloning. Clone the cell itself and all its children. const map = Cell.deepClone(this) return map[this.id] as any } findView(graph: Graph): CellView | null { return graph.findViewByCell(this) } // #endregion // #region batch startBatch( name: BatchName, data: KeyValue = {}, model: Model | null = this.model, ) { this.notify('batch:start', { name, data, cell: this }) if (model) { model.startBatch(name, { ...data, cell: this }) } return this } stopBatch( name: BatchName, data: KeyValue = {}, model: Model | null = this.model, ) { if (model) { model.stopBatch(name, { ...data, cell: this }) } this.notify('batch:stop', { name, data, cell: this }) return this } batchUpdate<T>(name: BatchName, execute: () => T, data?: KeyValue): T { // The model is null after cell was removed(remove batch). // So we should temp save model to trigger pairing batch event. const model = this.model this.startBatch(name, data, model) const result = execute() this.stopBatch(name, data, model) return result } // #endregion // #region IDisposable @disposable() dispose() { this.removeFromParent() this.animationManager.cancelAnimations() this.store.dispose() } // #endregion } export interface CellCommon { view?: string shape?: string markup?: MarkupType attrs?: CellAttrs zIndex?: number visible?: boolean data?: any } export interface CellDefaults extends CellCommon {} export interface CellMetadata extends CellCommon, KeyValue { id?: string tools?: ToolsLoose animation?: AnimateParams[] } export interface CellProperties extends CellDefaults, CellMetadata { parent?: string children?: string[] tools?: Tools } type ToolItem = | string | { name: string args?: any } export interface Tools { name?: string | null local?: boolean items: ToolItem[] } export type ToolsLoose = ToolItem | ToolItem[] | Tools export interface CellSetOptions extends StoreSetOptions {} export interface CellMutateOptions extends StoreMutateOptions {} export interface CellRemoveOptions extends CellSetOptions { deep?: boolean } export interface SetAttrOptions extends CellSetOptions { deep?: boolean overwrite?: boolean } export interface SetDataOptions extends CellSetOptions { deep?: boolean overwrite?: boolean } export interface SetByPathOptions extends StoreSetByPathOptions {} export interface ToFrontOptions extends CellSetOptions { deep?: boolean } export interface ToBackOptions extends ToFrontOptions {} export interface CellTranslateOptions extends CellSetOptions { tx?: number ty?: number translateBy?: string | number } export interface AddToolOptions extends CellSetOptions { reset?: boolean local?: boolean } export interface CellGetDescendantsOptions { deep?: boolean breadthFirst?: boolean } export interface CellToJSONOptions { diff?: boolean } export interface CloneOptions { deep?: boolean keepId?: boolean } export interface KeyframeAnimationOptions extends KeyframeEffectOptions { id?: string timeline?: AnimationTimeline | null } export type AnimateParams = Parameters<InstanceType<typeof Cell>['animate']> export interface CellBaseEventArgs { 'animation:finish': AnimationPlaybackEvent 'animation:cancel': AnimationPlaybackEvent // common 'change:*': ChangeAnyKeyArgs 'change:attrs': CellChangeArgs<CellAttrs> 'change:zIndex': CellChangeArgs<number> 'change:markup': CellChangeArgs<MarkupType> 'change:visible': CellChangeArgs<boolean> 'change:parent': CellChangeArgs<string> 'change:children': CellChangeArgs<string[]> 'change:tools': CellChangeArgs<Tools> 'change:view': CellChangeArgs<string> 'change:data': CellChangeArgs<any> // node 'change:size': NodeChangeArgs<Size> 'change:angle': NodeChangeArgs<number> 'change:position': NodeChangeArgs<PointLike> 'change:ports': NodeChangeArgs<Port[]> 'change:portMarkup': NodeChangeArgs<MarkupType> 'change:portLabelMarkup': NodeChangeArgs<MarkupType> 'change:portContainerMarkup': NodeChangeArgs<MarkupType> 'ports:removed': { cell: Cell node: Node removed: Port[] } 'ports:added': { cell: Cell node: Node added: Port[] } // edge 'change:source': EdgeChangeArgs<TerminalData> 'change:target': EdgeChangeArgs<TerminalData> 'change:terminal': EdgeChangeArgs<TerminalData> & { type: TerminalType } 'change:router': EdgeChangeArgs<RouterData> 'change:connector': EdgeChangeArgs<ConnectorData> 'change:vertices': EdgeChangeArgs<PointLike[]> 'change:labels': EdgeChangeArgs<EdgeLabel[]> 'change:defaultLabel': EdgeChangeArgs<EdgeLabel> 'vertexs:added': { cell: Cell edge: Edge added: PointLike[] } 'vertexs:removed': { cell: Cell edge: Edge removed: PointLike[] } 'labels:added': { cell: Cell edge: Edge added: EdgeLabel[] } 'labels:removed': { cell: Cell edge: Edge removed: EdgeLabel[] } 'batch:start': { name: BatchName data: KeyValue cell: Cell } 'batch:stop': { name: BatchName data: KeyValue cell: Cell } changed: { cell: Cell options: CellMutateOptions } added: { cell: Cell index: number options: CellSetOptions } removed: { cell: Cell index: number options: CellRemoveOptions } } interface ChangeAnyKeyArgs< T extends keyof CellProperties = keyof CellProperties, > { key: T current: CellProperties[T] previous: CellProperties[T] options: CellMutateOptions cell: Cell } export interface CellChangeArgs<T> { cell: Cell current?: T previous?: T options: CellMutateOptions } interface NodeChangeArgs<T> extends CellChangeArgs<T> { node: Node } interface EdgeChangeArgs<T> extends CellChangeArgs<T> { edge: Edge } export interface CellGetCellsBBoxOptions { deep?: boolean } export type CellDefinition = typeof Cell export type CellPropHook< M extends CellMetadata = CellMetadata, C extends Cell = Cell, > = (this: C, metadata: M) => M export type PropHooks< M extends CellMetadata = CellMetadata, C extends Cell = Cell, > = KeyValue<CellPropHook<M, C>> | CellPropHook<M, C> | CellPropHook<M, C>[] export interface CellConfig< M extends CellMetadata = CellMetadata, C extends Cell = Cell, > extends CellDefaults, KeyValue { constructorName?: string overwrite?: boolean propHooks?: PropHooks<M, C> attrHooks?: AttrDefinitions } Cell.config({ propHooks({ tools, ...metadata }) { if (tools) { metadata.tools = Cell.normalizeTools(tools) } return metadata }, })