@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
1,892 lines (1,602 loc) • 45.9 kB
text/typescript
/* 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
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
},
})