@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
1,046 lines (876 loc) • 26.3 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Nilable, KeyValue } from '../types'
import { Rectangle, Point } from '../geometry'
import { ArrayExt, ObjectExt, Dom, FunctionExt, Vector } from '../util'
import { Attr } from '../registry/attr'
import { Registry } from '../registry/registry'
import { ConnectionStrategy } from '../registry/connection-strategy'
import { Cell } from '../model/cell'
import { Edge } from '../model/edge'
import { Model } from '../model/model'
import { Graph } from '../graph/graph'
import { View } from './view'
import { Cache } from './cache'
import { Markup } from './markup'
import { EdgeView } from './edge'
import { NodeView } from './node'
import { ToolsView } from './tool'
import { AttrManager } from './attr'
import { FlagManager } from './flag'
export class CellView<
Entity extends Cell = Cell,
Options extends CellView.Options = CellView.Options,
> extends View<CellView.EventArgs> {
protected static defaults: Partial<CellView.Options> = {
isSvgElement: true,
rootSelector: 'root',
priority: 0,
bootstrap: [],
actions: {},
}
public static getDefaults() {
return this.defaults
}
public static config<T extends CellView.Options = CellView.Options>(
options: Partial<T>,
) {
this.defaults = this.getOptions(options)
}
public static getOptions<T extends CellView.Options = CellView.Options>(
options: Partial<T>,
): T {
const mergeActions = <T>(arr1: T | T[], arr2?: T | T[]) => {
if (arr2 != null) {
return ArrayExt.uniq([
...(Array.isArray(arr1) ? arr1 : [arr1]),
...(Array.isArray(arr2) ? arr2 : [arr2]),
])
}
return Array.isArray(arr1) ? [...arr1] : [arr1]
}
const ret = ObjectExt.cloneDeep(this.getDefaults()) as T
const { bootstrap, actions, events, documentEvents, ...others } = options
if (bootstrap) {
ret.bootstrap = mergeActions(ret.bootstrap, bootstrap)
}
if (actions) {
Object.keys(actions).forEach((key) => {
const val = actions[key]
const raw = ret.actions[key]
if (val && raw) {
ret.actions[key] = mergeActions(raw, val)
} else if (val) {
ret.actions[key] = mergeActions(val)
}
})
}
if (events) {
ret.events = { ...ret.events, ...events }
}
if (options.documentEvents) {
ret.documentEvents = { ...ret.documentEvents, ...documentEvents }
}
return ObjectExt.merge(ret, others) as T
}
public graph: Graph
public cell: Entity
protected selectors: Markup.Selectors
protected readonly options: Options
protected readonly flag: FlagManager
protected readonly attr: AttrManager
protected readonly cache: Cache
public scalableNode: Element | null
public rotatableNode: Element | null
protected get [Symbol.toStringTag]() {
return CellView.toStringTag
}
constructor(cell: Entity, options: Partial<Options> = {}) {
super()
this.cell = cell
this.options = this.ensureOptions(options)
this.graph = this.options.graph
this.attr = new AttrManager(this)
this.flag = new FlagManager(
this,
this.options.actions,
this.options.bootstrap,
)
this.cache = new Cache(this)
this.setContainer(this.ensureContainer())
this.setup()
this.$(this.container).data('view', this)
this.init()
}
protected init() {}
protected onRemove() {
this.removeTools()
}
public get priority() {
return this.options.priority
}
protected get rootSelector() {
return this.options.rootSelector
}
protected getConstructor<T extends CellView.Definition>() {
return this.constructor as any as T
}
protected ensureOptions(options: Partial<Options>) {
return this.getConstructor().getOptions(options) as Options
}
protected getContainerTagName(): string {
return this.options.isSvgElement ? 'g' : 'div'
}
protected getContainerStyle(): Nilable<
JQuery.PlainObject<string | number>
> | void {}
protected getContainerAttrs(): Nilable<Attr.SimpleAttrs> {
return {
'data-cell-id': this.cell.id,
'data-shape': this.cell.shape,
}
}
protected getContainerClassName(): Nilable<string | string[]> {
return this.prefixClassName('cell')
}
protected ensureContainer() {
return View.createElement(
this.getContainerTagName(),
this.options.isSvgElement,
)
}
protected setContainer(container: Element) {
if (this.container !== container) {
this.undelegateEvents()
this.container = container
if (this.options.events != null) {
this.delegateEvents(this.options.events)
}
const attrs = this.getContainerAttrs()
if (attrs != null) {
this.setAttrs(attrs, container)
}
const style = this.getContainerStyle()
if (style != null) {
this.setStyle(style, container)
}
const className = this.getContainerClassName()
if (className != null) {
this.addClass(className, container)
}
}
return this
}
isNodeView(): this is NodeView {
return false
}
isEdgeView(): this is EdgeView {
return false
}
render() {
return this
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
confirmUpdate(flag: number, options: any = {}) {
return 0
}
getBootstrapFlag() {
return this.flag.getBootstrapFlag()
}
getFlag(actions: FlagManager.Actions) {
return this.flag.getFlag(actions)
}
hasAction(flag: number, actions: FlagManager.Actions) {
return this.flag.hasAction(flag, actions)
}
removeAction(flag: number, actions: FlagManager.Actions) {
return this.flag.removeAction(flag, actions)
}
handleAction(
flag: number,
action: FlagManager.Action,
handle: () => void,
additionalRemovedActions?: FlagManager.Actions | null,
) {
if (this.hasAction(flag, action)) {
handle()
const removedFlags = [action]
if (additionalRemovedActions) {
if (typeof additionalRemovedActions === 'string') {
removedFlags.push(additionalRemovedActions)
} else {
removedFlags.push(...additionalRemovedActions)
}
}
return this.removeAction(flag, removedFlags)
}
return flag
}
protected setup() {
this.cell.on('changed', ({ options }) => this.onAttrsChange(options))
}
protected onAttrsChange(options: Cell.MutateOptions) {
let flag = this.flag.getChangedFlag()
if (options.updated || !flag) {
return
}
if (options.dirty && this.hasAction(flag, 'update')) {
flag |= this.getFlag('render') // eslint-disable-line no-bitwise
}
// tool changes should be sync render
if (options.toolId) {
options.async = false
}
if (this.graph != null) {
this.graph.renderer.requestViewUpdate(this, flag, this.priority, options)
}
}
parseJSONMarkup(
markup: Markup.JSONMarkup | Markup.JSONMarkup[],
rootElem?: Element,
) {
const result = Markup.parseJSONMarkup(markup)
const selectors = result.selectors
const rootSelector = this.rootSelector
if (rootElem && rootSelector) {
if (selectors[rootSelector]) {
throw new Error('Invalid root selector')
}
selectors[rootSelector] = rootElem
}
return result
}
can(feature: CellView.InteractionNames): boolean {
let interacting = this.graph.options.interacting
if (typeof interacting === 'function') {
interacting = FunctionExt.call(interacting, this.graph, this)
}
if (typeof interacting === 'object') {
let val = interacting[feature]
if (typeof val === 'function') {
val = FunctionExt.call(val, this.graph, this)
}
return val !== false
}
if (typeof interacting === 'boolean') {
return interacting
}
return false
}
cleanCache() {
this.cache.clean()
return this
}
getCache(elem: Element) {
return this.cache.get(elem)
}
getDataOfElement(elem: Element) {
return this.cache.getData(elem)
}
getMatrixOfElement(elem: Element) {
return this.cache.getMatrix(elem)
}
getShapeOfElement(elem: SVGElement) {
return this.cache.getShape(elem)
}
getScaleOfElement(node: Element, scalableNode?: SVGElement) {
let sx
let sy
if (scalableNode && scalableNode.contains(node)) {
const scale = Dom.scale(scalableNode)
sx = 1 / scale.sx
sy = 1 / scale.sy
} else {
sx = 1
sy = 1
}
return { sx, sy }
}
getBoundingRectOfElement(elem: Element) {
return this.cache.getBoundingRect(elem)
}
getBBoxOfElement(elem: Element) {
const rect = this.getBoundingRectOfElement(elem)
const matrix = this.getMatrixOfElement(elem)
const rm = this.getRootRotatedMatrix()
const tm = this.getRootTranslatedMatrix()
return Dom.transformRectangle(rect, tm.multiply(rm).multiply(matrix))
}
getUnrotatedBBoxOfElement(elem: SVGElement) {
const rect = this.getBoundingRectOfElement(elem)
const matrix = this.getMatrixOfElement(elem)
const tm = this.getRootTranslatedMatrix()
return Dom.transformRectangle(rect, tm.multiply(matrix))
}
getBBox(options: { useCellGeometry?: boolean } = {}) {
let bbox
if (options.useCellGeometry) {
const cell = this.cell
const angle = cell.isNode() ? cell.getAngle() : 0
bbox = cell.getBBox().bbox(angle)
} else {
bbox = this.getBBoxOfElement(this.container)
}
return this.graph.localToGraph(bbox)
}
getRootTranslatedMatrix() {
const cell = this.cell
const pos = cell.isNode() ? cell.getPosition() : { x: 0, y: 0 }
return Dom.createSVGMatrix().translate(pos.x, pos.y)
}
getRootRotatedMatrix() {
let matrix = Dom.createSVGMatrix()
const cell = this.cell
const angle = cell.isNode() ? cell.getAngle() : 0
if (angle) {
const bbox = cell.getBBox()
const cx = bbox.width / 2
const cy = bbox.height / 2
matrix = matrix.translate(cx, cy).rotate(angle).translate(-cx, -cy)
}
return matrix
}
findMagnet(elem: Element = this.container) {
// If the overall cell has set `magnet === false`, then returns
// `undefined` to announce there is no magnet found for this cell.
// This is especially useful to set on cells that have 'ports'.
// In this case, only the ports have set `magnet === true` and the
// overall element has `magnet === false`.
return this.findByAttr('magnet', elem)
}
updateAttrs(
rootNode: Element,
attrs: Attr.CellAttrs,
options: Partial<AttrManager.UpdateOptions> = {},
) {
if (options.rootBBox == null) {
options.rootBBox = new Rectangle()
}
if (options.selectors == null) {
options.selectors = this.selectors
}
this.attr.update(rootNode, attrs, options as AttrManager.UpdateOptions)
}
isEdgeElement(magnet?: Element | null) {
return this.cell.isEdge() && (magnet == null || magnet === this.container)
}
// #region highlight
protected prepareHighlight(
elem?: Element | null,
options: CellView.HighlightOptions = {},
) {
const magnet = (elem && this.$(elem)[0]) || this.container
options.partial = magnet === this.container
return magnet
}
highlight(elem?: Element | null, options: CellView.HighlightOptions = {}) {
const magnet = this.prepareHighlight(elem, options)
this.notify('cell:highlight', {
magnet,
options,
view: this,
cell: this.cell,
})
if (this.isEdgeView()) {
this.notify('edge:highlight', {
magnet,
options,
view: this,
edge: this.cell,
cell: this.cell,
})
} else if (this.isNodeView()) {
this.notify('node:highlight', {
magnet,
options,
view: this,
node: this.cell,
cell: this.cell,
})
}
return this
}
unhighlight(elem?: Element | null, options: CellView.HighlightOptions = {}) {
const magnet = this.prepareHighlight(elem, options)
this.notify('cell:unhighlight', {
magnet,
options,
view: this,
cell: this.cell,
})
if (this.isNodeView()) {
this.notify('node:unhighlight', {
magnet,
options,
view: this,
node: this.cell,
cell: this.cell,
})
} else if (this.isEdgeView()) {
this.notify('edge:unhighlight', {
magnet,
options,
view: this,
edge: this.cell,
cell: this.cell,
})
}
return this
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
notifyUnhighlight(magnet: Element, options: CellView.HighlightOptions) {}
// #endregion
getEdgeTerminal(
magnet: Element,
x: number,
y: number,
edge: Edge,
type: Edge.TerminalType,
) {
const cell = this.cell
const portId = this.findAttr('port', magnet)
const selector = magnet.getAttribute('data-selector')
const terminal: Edge.TerminalCellData = { cell: cell.id }
if (selector != null) {
terminal.magnet = selector
}
if (portId != null) {
terminal.port = portId
if (cell.isNode()) {
if (!cell.hasPort(portId) && selector == null) {
// port created via the `port` attribute (not API)
terminal.selector = this.getSelector(magnet)
}
}
} else if (selector == null && this.container !== magnet) {
terminal.selector = this.getSelector(magnet)
}
return this.customizeEdgeTerminal(terminal, magnet, x, y, edge, type)
}
protected customizeEdgeTerminal(
terminal: Edge.TerminalCellData,
magnet: Element,
x: number,
y: number,
edge: Edge,
type: Edge.TerminalType,
): Edge.TerminalCellData {
const raw = edge.getStrategy() || this.graph.options.connecting.strategy
if (raw) {
const name = typeof raw === 'string' ? raw : raw.name
const args = typeof raw === 'string' ? {} : raw.args || {}
const registry = ConnectionStrategy.registry
if (name) {
const fn = registry.get(name)
if (fn == null) {
return registry.onNotFound(name)
}
const result = FunctionExt.call(
fn,
this.graph,
terminal,
this,
magnet,
new Point(x, y),
edge,
type,
args,
)
if (result) {
return result
}
}
}
return terminal
}
getMagnetFromEdgeTerminal(terminal: Edge.TerminalData) {
const cell = this.cell
const root = this.container
const portId = (terminal as Edge.TerminalCellData).port
let selector = terminal.magnet
let magnet
if (portId != null && cell.isNode() && cell.hasPort(portId)) {
magnet = (this as any).findPortElem(portId, selector) || root
} else {
if (!selector) {
selector = terminal.selector
}
if (!selector && portId != null) {
selector = `[port="${portId}"]`
}
magnet = this.findOne(selector, root, this.selectors)
}
return magnet
}
// #region animate
animate(elem: SVGElement | string, options: Dom.AnimationOptions) {
const target = typeof elem === 'string' ? this.findOne(elem) : elem
if (target == null) {
throw new Error('Invalid animation element.')
}
const parent = target.parentNode
const revert = () => {
if (!parent) {
Dom.remove(target)
}
}
const vTarget = Vector.create(target as SVGElement)
if (!parent) {
vTarget.appendTo(this.graph.view.stage)
}
const onComplete = options.complete
options.complete = (e: Event) => {
revert()
if (onComplete) {
onComplete(e)
}
}
return vTarget.animate(options)
}
animateTransform(elem: SVGElement | string, options: Dom.AnimationOptions) {
const target = typeof elem === 'string' ? this.findOne(elem) : elem
if (target == null) {
throw new Error('Invalid animation element.')
}
const parent = target.parentNode
const revert = () => {
if (!parent) {
Dom.remove(target)
}
}
const vTarget = Vector.create(target as SVGElement)
if (!parent) {
vTarget.appendTo(this.graph.view.stage)
}
const onComplete = options.complete
options.complete = (e: Event) => {
revert()
if (onComplete) {
onComplete(e)
}
}
return vTarget.animateTransform(options)
}
// #endregion
// #region tools
protected tools: ToolsView | null
hasTools(name?: string) {
const tools = this.tools
if (tools == null) {
return false
}
if (name == null) {
return true
}
return tools.name === name
}
addTools(options: ToolsView.Options | null): this
addTools(tools: ToolsView | null): this
addTools(config: ToolsView | ToolsView.Options | null) {
if (!this.can('toolsAddable')) {
return this
}
this.removeTools()
if (config) {
const tools = ToolsView.isToolsView(config)
? config
: new ToolsView(config)
this.tools = tools
this.graph.on('tools:hide', this.hideTools, this)
this.graph.on('tools:show', this.showTools, this)
this.graph.on('tools:remove', this.removeTools, this)
tools.config({ view: this })
tools.mount()
}
return this
}
updateTools(options: ToolsView.UpdateOptions = {}) {
if (this.tools) {
this.tools.update(options)
}
return this
}
removeTools() {
if (this.tools) {
this.tools.remove()
this.graph.off('tools:hide', this.hideTools, this)
this.graph.off('tools:show', this.showTools, this)
this.graph.off('tools:remove', this.removeTools, this)
this.tools = null
}
return this
}
hideTools() {
if (this.tools) {
this.tools.hide()
}
return this
}
showTools() {
if (this.tools) {
this.tools.show()
}
return this
}
protected renderTools() {
const tools = this.cell.getTools()
this.addTools(tools as ToolsView.Options)
return this
}
// #endregion
// #region events
notify<Key extends keyof CellView.EventArgs>(
name: Key,
args: CellView.EventArgs[Key],
): this
notify(name: Exclude<string, keyof CellView.EventArgs>, args: any): this
notify<Key extends keyof CellView.EventArgs>(
name: Key,
args: CellView.EventArgs[Key],
) {
this.trigger(name, args)
this.graph.trigger(name, args)
return this
}
protected getEventArgs<E>(e: E): CellView.MouseEventArgs<E>
protected getEventArgs<E>(
e: E,
x: number,
y: number,
): CellView.MousePositionEventArgs<E>
protected getEventArgs<E>(e: E, x?: number, y?: number) {
const view = this // eslint-disable-line @typescript-eslint/no-this-alias
const cell = view.cell
if (x == null || y == null) {
return { e, view, cell } as CellView.MouseEventArgs<E>
}
return { e, x, y, view, cell } as CellView.MousePositionEventArgs<E>
}
onClick(e: JQuery.ClickEvent, x: number, y: number) {
this.notify('cell:click', this.getEventArgs(e, x, y))
}
onDblClick(e: JQuery.DoubleClickEvent, x: number, y: number) {
this.notify('cell:dblclick', this.getEventArgs(e, x, y))
}
onContextMenu(e: JQuery.ContextMenuEvent, x: number, y: number) {
this.notify('cell:contextmenu', this.getEventArgs(e, x, y))
}
protected cachedModelForMouseEvent: Model | null
onMouseDown(e: JQuery.MouseDownEvent, x: number, y: number) {
if (this.cell.model) {
this.cachedModelForMouseEvent = this.cell.model
this.cachedModelForMouseEvent.startBatch('mouse')
}
this.notify('cell:mousedown', this.getEventArgs(e, x, y))
}
onMouseUp(e: JQuery.MouseUpEvent, x: number, y: number) {
this.notify('cell:mouseup', this.getEventArgs(e, x, y))
if (this.cachedModelForMouseEvent) {
this.cachedModelForMouseEvent.stopBatch('mouse', { cell: this.cell })
this.cachedModelForMouseEvent = null
}
}
onMouseMove(e: JQuery.MouseMoveEvent, x: number, y: number) {
this.notify('cell:mousemove', this.getEventArgs(e, x, y))
}
onMouseOver(e: JQuery.MouseOverEvent) {
this.notify('cell:mouseover', this.getEventArgs(e))
}
onMouseOut(e: JQuery.MouseOutEvent) {
this.notify('cell:mouseout', this.getEventArgs(e))
}
onMouseEnter(e: JQuery.MouseEnterEvent) {
this.notify('cell:mouseenter', this.getEventArgs(e))
}
onMouseLeave(e: JQuery.MouseLeaveEvent) {
this.notify('cell:mouseleave', this.getEventArgs(e))
}
onMouseWheel(e: JQuery.TriggeredEvent, x: number, y: number, delta: number) {
this.notify('cell:mousewheel', {
delta,
...this.getEventArgs(e, x, y),
})
}
onCustomEvent(e: JQuery.MouseDownEvent, name: string, x: number, y: number) {
this.notify('cell:customevent', { name, ...this.getEventArgs(e, x, y) })
this.notify(name, { ...this.getEventArgs(e, x, y) })
}
onMagnetMouseDown(
e: JQuery.MouseDownEvent,
magnet: Element,
x: number,
y: number,
) {}
onMagnetDblClick(
e: JQuery.DoubleClickEvent,
magnet: Element,
x: number,
y: number,
) {}
onMagnetContextMenu(
e: JQuery.ContextMenuEvent,
magnet: Element,
x: number,
y: number,
) {}
onLabelMouseDown(e: JQuery.MouseDownEvent, x: number, y: number) {}
checkMouseleave(e: JQuery.TriggeredEvent) {
const graph = this.graph
if (graph.renderer.isAsync()) {
// Do the updates of the current view synchronously now
graph.renderer.dumpView(this)
}
const target = this.getEventTarget(e, { fromPoint: true })
const view = graph.renderer.findViewByElem(target)
if (view === this) {
return
}
// Leaving the current view
this.onMouseLeave(e as JQuery.MouseLeaveEvent)
if (!view) {
return
}
// Entering another view
view.onMouseEnter(e as JQuery.MouseEnterEvent)
}
// #endregion
}
export namespace CellView {
export interface Options {
graph: Graph
priority: number
isSvgElement: boolean
rootSelector: string
bootstrap: FlagManager.Actions
actions: KeyValue<FlagManager.Actions>
events?: View.Events | null
documentEvents?: View.Events | null
}
type Interactable = boolean | ((this: Graph, cellView: CellView) => boolean)
interface InteractionMap {
// edge
edgeMovable?: Interactable
edgeLabelMovable?: Interactable
arrowheadMovable?: Interactable
vertexMovable?: Interactable
vertexAddable?: Interactable
vertexDeletable?: Interactable
useEdgeTools?: Interactable
// node
nodeMovable?: Interactable
magnetConnectable?: Interactable
stopDelegateOnDragging?: Interactable
// general
toolsAddable?: Interactable
}
export type InteractionNames = keyof InteractionMap
export type Interacting =
| boolean
| InteractionMap
| ((this: Graph, cellView: CellView) => InteractionMap | boolean)
export interface HighlightOptions {
highlighter?:
| string
| {
name: string
args: KeyValue
}
type?: 'embedding' | 'nodeAvailable' | 'magnetAvailable' | 'magnetAdsorbed'
partial?: boolean
}
}
export namespace CellView {
export interface PositionEventArgs {
x: number
y: number
}
export interface MouseDeltaEventArgs {
delta: number
}
export interface MouseEventArgs<E> {
e: E
view: CellView
cell: Cell
}
export interface MousePositionEventArgs<E>
extends MouseEventArgs<E>,
PositionEventArgs {}
export interface EventArgs extends NodeView.EventArgs, EdgeView.EventArgs {
'cell:click': MousePositionEventArgs<JQuery.ClickEvent>
'cell:dblclick': MousePositionEventArgs<JQuery.DoubleClickEvent>
'cell:contextmenu': MousePositionEventArgs<JQuery.ContextMenuEvent>
'cell:mousedown': MousePositionEventArgs<JQuery.MouseDownEvent>
'cell:mousemove': MousePositionEventArgs<JQuery.MouseMoveEvent>
'cell:mouseup': MousePositionEventArgs<JQuery.MouseUpEvent>
'cell:mouseover': MouseEventArgs<JQuery.MouseOverEvent>
'cell:mouseout': MouseEventArgs<JQuery.MouseOutEvent>
'cell:mouseenter': MouseEventArgs<JQuery.MouseEnterEvent>
'cell:mouseleave': MouseEventArgs<JQuery.MouseLeaveEvent>
'cell:mousewheel': MousePositionEventArgs<JQuery.TriggeredEvent> &
MouseDeltaEventArgs
'cell:customevent': MousePositionEventArgs<JQuery.MouseDownEvent> & {
name: string
}
'cell:highlight': {
magnet: Element
view: CellView
cell: Cell
options: CellView.HighlightOptions
}
'cell:unhighlight': EventArgs['cell:highlight']
}
}
export namespace CellView {
export const Flag = FlagManager
export const Attr = AttrManager
}
export namespace CellView {
export const toStringTag = `X6.${CellView.name}`
export function isCellView(instance: any): instance is CellView {
if (instance == null) {
return false
}
if (instance instanceof CellView) {
return true
}
const tag = instance[Symbol.toStringTag]
const view = instance as CellView
if (
(tag == null || tag === toStringTag) &&
typeof view.isNodeView === 'function' &&
typeof view.isEdgeView === 'function' &&
typeof view.confirmUpdate === 'function'
) {
return true
}
return false
}
}
// decorators
// ----
export namespace CellView {
export function priority(value: number) {
return function (ctor: Definition) {
ctor.config({ priority: value })
}
}
export function bootstrap(actions: FlagManager.Actions) {
return function (ctor: Definition) {
ctor.config({ bootstrap: actions })
}
}
}
export namespace CellView {
type CellViewClass = typeof CellView
export interface Definition extends CellViewClass {
new <
Entity extends Cell = Cell,
Options extends CellView.Options = CellView.Options,
>(
cell: Entity,
options: Partial<Options>,
): CellView
}
export const registry = Registry.create<Definition>({
type: 'view',
})
}