UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering.

1,353 lines (1,156 loc) 38.2 kB
import { Util, Config } from '../global' import { ArrayExt, FunctionExt, Dom, Vector } from '../util' import { Rectangle, Point } from '../geometry' import { Attr, PortLayout } from '../registry' import { Cell } from '../model/cell' import { Node } from '../model/node' import { PortManager } from '../model/port' import { Graph } from '../graph' import { CellView } from './cell' import { EdgeView } from './edge' import { Markup } from './markup' import { AttrManager } from './attr' export class NodeView< Entity extends Node = Node, Options extends NodeView.Options = NodeView.Options, > extends CellView<Entity, Options> { public scalableNode: Element | null = null public rotatableNode: Element | null = null protected readonly scalableSelector: string = 'scalable' protected readonly rotatableSelector: string = 'rotatable' protected readonly defaultPortMarkup = Markup.getPortMarkup() protected readonly defaultPortLabelMarkup = Markup.getPortLabelMarkup() protected readonly defaultPortContainerMarkup = Markup.getPortContainerMarkup() protected portsCache: { [id: string]: NodeView.PortCache } = {} protected get [Symbol.toStringTag]() { return NodeView.toStringTag } protected getContainerClassName() { const classList = [ super.getContainerClassName(), this.prefixClassName('node'), ] if (!this.can('nodeMovable')) { classList.push(this.prefixClassName('node-immovable')) } return classList.join(' ') } protected updateClassName(e: JQuery.MouseEnterEvent) { const target = e.target if (target.hasAttribute('magnet')) { // port const className = this.prefixClassName('port-unconnectable') if (this.can('magnetConnectable')) { Dom.removeClass(target, className) } else { Dom.addClass(target, className) } } else { // node const className = this.prefixClassName('node-immovable') if (this.can('nodeMovable')) { this.removeClass(className) } else { this.addClass(className) } } } isNodeView(): this is NodeView { return true } confirmUpdate(flag: number, options: any = {}) { let ret = flag if (this.hasAction(ret, 'ports')) { this.removePorts() this.cleanPortsCache() } if (this.hasAction(ret, 'render')) { this.render() ret = this.removeAction(ret, [ 'render', 'update', 'resize', 'translate', 'rotate', 'ports', 'tools', ]) } else { ret = this.handleAction( ret, 'resize', () => this.resize(options), 'update', // Resize method is calling `update()` internally ) ret = this.handleAction( ret, 'update', () => this.update(), // `update()` will render ports when useCSSSelectors are enabled Config.useCSSSelector ? 'ports' : null, ) ret = this.handleAction(ret, 'translate', () => this.translate()) ret = this.handleAction(ret, 'rotate', () => this.rotate()) ret = this.handleAction(ret, 'ports', () => this.renderPorts()) ret = this.handleAction(ret, 'tools', () => this.renderTools()) } return ret } update(partialAttrs?: Attr.CellAttrs) { this.cleanCache() // When CSS selector strings are used, make sure no rule matches port nodes. if (Config.useCSSSelector) { this.removePorts() } const node = this.cell const size = node.getSize() const attrs = node.getAttrs() this.updateAttrs(this.container, attrs, { attrs: partialAttrs === attrs ? null : partialAttrs, rootBBox: new Rectangle(0, 0, size.width, size.height), selectors: this.selectors, scalableNode: this.scalableNode, rotatableNode: this.rotatableNode, }) if (Config.useCSSSelector) { this.renderPorts() } } protected renderMarkup() { const markup = this.cell.markup if (markup) { if (typeof markup === 'string') { return this.renderStringMarkup(markup) } return this.renderJSONMarkup(markup) } throw new TypeError('Invalid node markup.') } protected renderJSONMarkup(markup: Markup.JSONMarkup | Markup.JSONMarkup[]) { const ret = this.parseJSONMarkup(markup, this.container) const one = (elems: Element | Element[] | null) => Array.isArray(elems) ? elems[0] : elems this.selectors = ret.selectors this.rotatableNode = one(this.selectors[this.rotatableSelector]) this.scalableNode = one(this.selectors[this.scalableSelector]) this.container.appendChild(ret.fragment) } protected renderStringMarkup(markup: string) { Dom.append(this.container, Vector.toNodes(Vector.createVectors(markup))) this.rotatableNode = Dom.findOne( this.container, `.${this.rotatableSelector}`, ) this.scalableNode = Dom.findOne(this.container, `.${this.scalableSelector}`) this.selectors = {} if (this.rootSelector) { this.selectors[this.rootSelector] = this.container } } render() { this.empty() this.renderMarkup() if (this.scalableNode) { // Double update is necessary for elements with the scalable group only // Note the `resize()` triggers the other `update`. this.update() } this.resize() if (this.rotatableNode) { this.rotate() this.translate() } else { this.updateTransform() } if (!Config.useCSSSelector) { this.renderPorts() } this.renderTools() return this } resize(opt: any = {}) { if (this.scalableNode) { return this.updateSize(opt) } if (this.cell.getAngle()) { this.rotate() } this.update() } translate() { if (this.rotatableNode) { return this.updateTranslation() } this.updateTransform() } rotate() { if (this.rotatableNode) { this.updateRotation() // It's necessary to call the update for the nodes outside // the rotatable group referencing nodes inside the group this.update() return } this.updateTransform() } protected getTranslationString() { const position = this.cell.getPosition() return `translate(${position.x},${position.y})` } protected getRotationString() { const angle = this.cell.getAngle() if (angle) { const size = this.cell.getSize() return `rotate(${angle},${size.width / 2},${size.height / 2})` } } protected updateTransform() { let transform = this.getTranslationString() const rot = this.getRotationString() if (rot) { transform += ` ${rot}` } this.container.setAttribute('transform', transform) } protected updateRotation() { if (this.rotatableNode != null) { const transform = this.getRotationString() if (transform != null) { this.rotatableNode.setAttribute('transform', transform) } else { this.rotatableNode.removeAttribute('transform') } } } protected updateTranslation() { this.container.setAttribute('transform', this.getTranslationString()) } protected updateSize(opt: any = {}) { const cell = this.cell const size = cell.getSize() const angle = cell.getAngle() const scalableNode = this.scalableNode! // Getting scalable group's bbox. // Due to a bug in webkit's native SVG .getBBox implementation, the // bbox of groups with path children includes the paths' control points. // To work around the issue, we need to check whether there are any path // elements inside the scalable group. let recursive = false if (scalableNode.getElementsByTagName('path').length > 0) { // If scalable has at least one descendant that is a path, we need // toswitch to recursive bbox calculation. Otherwise, group bbox // calculation works and so we can use the (faster) native function. recursive = true } const scalableBBox = Dom.getBBox(scalableNode as SVGElement, { recursive }) // Make sure `scalableBbox.width` and `scalableBbox.height` are not zero // which can happen if the element does not have any content. const sx = size.width / (scalableBBox.width || 1) const sy = size.height / (scalableBBox.height || 1) scalableNode.setAttribute('transform', `scale(${sx},${sy})`) // Now the interesting part. The goal is to be able to store the object geometry via just `x`, `y`, `angle`, `width` and `height` // Order of transformations is significant but we want to reconstruct the object always in the order: // resize(), rotate(), translate() no matter of how the object was transformed. For that to work, // we must adjust the `x` and `y` coordinates of the object whenever we resize it (because the origin of the // rotation changes). The new `x` and `y` coordinates are computed by canceling the previous rotation // around the center of the resized object (which is a different origin then the origin of the previous rotation) // and getting the top-left corner of the resulting object. Then we clean up the rotation back to what it originally was. // Cancel the rotation but now around a different origin, which is the center of the scaled object. const rotatableNode = this.rotatableNode if (rotatableNode != null) { const transform = rotatableNode.getAttribute('transform') if (transform) { rotatableNode.setAttribute( 'transform', `${transform} rotate(${-angle},${size.width / 2},${size.height / 2})`, ) const rotatableBBox = Dom.getBBox(scalableNode as SVGElement, { target: this.graph.view.stage, }) // Store new x, y and perform rotate() again against the new rotation origin. cell.prop( 'position', { x: rotatableBBox.x, y: rotatableBBox.y }, { updated: true, ...opt }, ) this.translate() this.rotate() } } // Update must always be called on non-rotated element. Otherwise, // relative positioning would work with wrong (rotated) bounding boxes. this.update() } // #region ports findPortElem(portId?: string, selector?: string) { const cache = portId ? this.portsCache[portId] : null if (!cache) { return null } const portRoot = cache.portContentElement const portSelectors = cache.portContentSelectors || {} return this.findOne(selector, portRoot, portSelectors) } protected initializePorts() { this.cleanPortsCache() } protected refreshPorts() { this.removePorts() this.cleanPortsCache() this.renderPorts() } protected cleanPortsCache() { this.portsCache = {} } protected removePorts() { Object.keys(this.portsCache).forEach((portId) => { const cached = this.portsCache[portId] Dom.remove(cached.portElement) }) } protected renderPorts() { const container = this.getPortsContainer() // References to rendered elements without z-index const references: Element[] = [] container.childNodes.forEach((child) => { references.push(child as Element) }) const portsGropsByZ = ArrayExt.groupBy(this.cell.getParsedPorts(), 'zIndex') const autoZIndexKey = 'auto' // render non-z first if (portsGropsByZ[autoZIndexKey]) { portsGropsByZ[autoZIndexKey].forEach((port) => { const portElement = this.getPortElement(port) container.append(portElement) references.push(portElement) }) } Object.keys(portsGropsByZ).forEach((key) => { if (key !== autoZIndexKey) { const zIndex = parseInt(key, 10) this.appendPorts(portsGropsByZ[key], zIndex, references) } }) this.updatePorts() } protected getPortsContainer() { return this.rotatableNode || this.container } protected appendPorts( ports: PortManager.Port[], zIndex: number, refs: Element[], ) { const elems = ports.map((p) => this.getPortElement(p)) if (refs[zIndex] || zIndex < 0) { Dom.before(refs[Math.max(zIndex, 0)], elems) } else { Dom.append(this.getPortsContainer(), elems) } } protected getPortElement(port: PortManager.Port) { const cached = this.portsCache[port.id] if (cached) { return cached.portElement } return this.createPortElement(port) } protected createPortElement(port: PortManager.Port) { let renderResult = Markup.renderMarkup(this.getPortContainerMarkup()) const portElement = renderResult.elem if (portElement == null) { throw new Error('Invalid port container markup.') } renderResult = Markup.renderMarkup(this.getPortMarkup(port)) const portContentElement = renderResult.elem const portContentSelectors = renderResult.selectors if (portContentElement == null) { throw new Error('Invalid port markup.') } this.setAttrs( { port: port.id, 'port-group': port.group, }, portContentElement, ) renderResult = Markup.renderMarkup(this.getPortLabelMarkup(port.label)) const portLabelElement = renderResult.elem const portLabelSelectors = renderResult.selectors if (portLabelElement == null) { throw new Error('Invalid port label markup.') } let portSelectors: Markup.Selectors | undefined if (portContentSelectors && portLabelSelectors) { // eslint-disable-next-line for (const key in portLabelSelectors) { if (portContentSelectors[key] && key !== this.rootSelector) { throw new Error('Selectors within port must be unique.') } } portSelectors = { ...portContentSelectors, ...portLabelSelectors, } } else { portSelectors = portContentSelectors || portLabelSelectors } Dom.addClass(portElement, 'x6-port') Dom.addClass(portContentElement, 'x6-port-body') Dom.addClass(portLabelElement, 'x6-port-label') portElement.appendChild(portContentElement) portElement.appendChild(portLabelElement) this.portsCache[port.id] = { portElement, portSelectors, portLabelElement, portLabelSelectors, portContentElement, portContentSelectors, } this.graph.hook.onPortRendered({ port, node: this.cell, container: portElement, selectors: portSelectors, labelContainer: portLabelElement, labelSelectors: portLabelSelectors, contentContainer: portContentElement, contentSelectors: portContentSelectors, }) return portElement } protected updatePorts() { // Layout ports without group this.updatePortGroup() // Layout ports with explicit group const groups = this.cell.getParsedGroups() Object.keys(groups).forEach((groupName) => this.updatePortGroup(groupName)) } protected updatePortGroup(groupName?: string) { const bbox = Rectangle.fromSize(this.cell.getSize()) const metrics = this.cell.getPortsLayoutByGroup(groupName, bbox) for (let i = 0, n = metrics.length; i < n; i += 1) { const metric = metrics[i] const portId = metric.portId const cached = this.portsCache[portId] || {} const portLayout = metric.portLayout this.applyPortTransform(cached.portElement, portLayout) if (metric.portAttrs != null) { const options: Partial<AttrManager.UpdateOptions> = { selectors: cached.portSelectors || {}, } if (metric.portSize) { options.rootBBox = Rectangle.fromSize(metric.portSize) } this.updateAttrs(cached.portElement, metric.portAttrs, options) } const labelLayout = metric.labelLayout if (labelLayout) { this.applyPortTransform( cached.portLabelElement, labelLayout, -(portLayout.angle || 0), ) if (labelLayout.attrs) { const options: Partial<AttrManager.UpdateOptions> = { selectors: cached.portLabelSelectors || {}, } if (metric.labelSize) { options.rootBBox = Rectangle.fromSize(metric.labelSize) } this.updateAttrs(cached.portLabelElement, labelLayout.attrs, options) } } } } protected applyPortTransform( element: Element, layout: PortLayout.Result, initialAngle = 0, ) { const angle = layout.angle const position = layout.position const matrix = Dom.createSVGMatrix() .rotate(initialAngle) .translate(position.x || 0, position.y || 0) .rotate(angle || 0) Dom.transform(element as SVGElement, matrix, { absolute: true }) } protected getPortContainerMarkup() { return this.cell.getPortContainerMarkup() || this.defaultPortContainerMarkup } protected getPortMarkup(port: PortManager.Port) { return port.markup || this.cell.portMarkup || this.defaultPortMarkup } protected getPortLabelMarkup(label: PortManager.Label) { return ( label.markup || this.cell.portLabelMarkup || this.defaultPortLabelMarkup ) } // #endregion // #region events protected getEventArgs<E>(e: E): NodeView.MouseEventArgs<E> protected getEventArgs<E>( e: E, x: number, y: number, ): NodeView.PositionEventArgs<E> protected getEventArgs<E>(e: E, x?: number, y?: number) { const view = this // eslint-disable-line const node = view.cell const cell = node if (x == null || y == null) { return { e, view, node, cell } as NodeView.MouseEventArgs<E> } return { e, x, y, view, node, cell } as NodeView.PositionEventArgs<E> } notifyMouseDown(e: JQuery.MouseDownEvent, x: number, y: number) { super.onMouseDown(e, x, y) this.notify('node:mousedown', this.getEventArgs(e, x, y)) } notifyMouseMove(e: JQuery.MouseMoveEvent, x: number, y: number) { super.onMouseMove(e, x, y) this.notify('node:mousemove', this.getEventArgs(e, x, y)) } notifyMouseUp(e: JQuery.MouseUpEvent, x: number, y: number) { super.onMouseUp(e, x, y) this.notify('node:mouseup', this.getEventArgs(e, x, y)) } onClick(e: JQuery.ClickEvent, x: number, y: number) { super.onClick(e, x, y) this.notify('node:click', this.getEventArgs(e, x, y)) } onDblClick(e: JQuery.DoubleClickEvent, x: number, y: number) { super.onDblClick(e, x, y) this.notify('node:dblclick', this.getEventArgs(e, x, y)) } onContextMenu(e: JQuery.ContextMenuEvent, x: number, y: number) { super.onContextMenu(e, x, y) this.notify('node:contextmenu', this.getEventArgs(e, x, y)) } onMouseDown(e: JQuery.MouseDownEvent, x: number, y: number) { if (this.isPropagationStopped(e)) { return } this.notifyMouseDown(e, x, y) this.startNodeDragging(e, x, y) } onMouseMove(e: JQuery.MouseMoveEvent, x: number, y: number) { const data = this.getEventData<EventData.Mousemove>(e) const action = data.action if (action === 'magnet') { this.dragMagnet(e, x, y) } else { if (action === 'move') { const meta = data as EventData.Moving const view = meta.targetView || this view.dragNode(e, x, y) view.notify('node:moving', { e, x, y, view, cell: view.cell, node: view.cell, }) } this.notifyMouseMove(e, x, y) } this.setEventData<EventData.Mousemove>(e, data) } onMouseUp(e: JQuery.MouseUpEvent, x: number, y: number) { const data = this.getEventData<EventData.Mousemove>(e) const action = data.action if (action === 'magnet') { this.stopMagnetDragging(e, x, y) } else { this.notifyMouseUp(e, x, y) if (action === 'move') { const meta = data as EventData.Moving const view = meta.targetView || this view.stopNodeDragging(e, x, y) } } const magnet = (data as EventData.Magnet).targetMagnet if (magnet) { this.onMagnetClick(e, magnet, x, y) } this.checkMouseleave(e) } onMouseOver(e: JQuery.MouseOverEvent) { super.onMouseOver(e) this.notify('node:mouseover', this.getEventArgs(e)) } onMouseOut(e: JQuery.MouseOutEvent) { super.onMouseOut(e) this.notify('node:mouseout', this.getEventArgs(e)) } onMouseEnter(e: JQuery.MouseEnterEvent) { this.updateClassName(e) super.onMouseEnter(e) this.notify('node:mouseenter', this.getEventArgs(e)) } onMouseLeave(e: JQuery.MouseLeaveEvent) { super.onMouseLeave(e) this.notify('node:mouseleave', this.getEventArgs(e)) } onMouseWheel(e: JQuery.TriggeredEvent, x: number, y: number, delta: number) { super.onMouseWheel(e, x, y, delta) this.notify('node:mousewheel', { delta, ...this.getEventArgs(e, x, y), }) } onMagnetClick(e: JQuery.MouseUpEvent, magnet: Element, x: number, y: number) { const count = this.graph.view.getMouseMovedCount(e) if (count > this.graph.options.clickThreshold) { return } this.notify('node:magnet:click', { magnet, ...this.getEventArgs(e, x, y), }) } onMagnetDblClick( e: JQuery.DoubleClickEvent, magnet: Element, x: number, y: number, ) { this.notify('node:magnet:dblclick', { magnet, ...this.getEventArgs(e, x, y), }) } onMagnetContextMenu( e: JQuery.ContextMenuEvent, magnet: Element, x: number, y: number, ) { this.notify('node:magnet:contextmenu', { magnet, ...this.getEventArgs(e, x, y), }) } onMagnetMouseDown( e: JQuery.MouseDownEvent, magnet: Element, x: number, y: number, ) { this.startMagnetDragging(e, x, y) } onCustomEvent(e: JQuery.MouseDownEvent, name: string, x: number, y: number) { this.notify('node:customevent', { name, ...this.getEventArgs(e, x, y) }) super.onCustomEvent(e, name, x, y) } protected prepareEmbedding(e: JQuery.MouseMoveEvent) { // const cell = data.cell || this.cell // const graph = data.graph || this.graph // const model = graph.model // model.startBatch('to-front') // // Bring the model to the front with all his embeds. // cell.toFront({ deep: true, ui: true }) // const maxZ = model // .getNodes() // .reduce((max, cell) => Math.max(max, cell.getZIndex() || 0), 0) // const connectedEdges = model.getConnectedEdges(cell, { // deep: true, // enclosed: true, // }) // connectedEdges.forEach((edge) => { // const zIndex = edge.getZIndex() || 0 // if (zIndex <= maxZ) { // edge.setZIndex(maxZ + 1, { ui: true }) // } // }) // model.stopBatch('to-front') // Before we start looking for suitable parent we remove the current one. // const parent = cell.getParent() // if (parent) { // parent.unembed(cell, { ui: true }) // } const data = this.getEventData<EventData.MovingTargetNode>(e) const node = data.cell || this.cell const view = this.graph.findViewByCell(node) const localPoint = this.graph.snapToGrid(e.clientX, e.clientY) this.notify('node:embed', { e, node, view, cell: node, x: localPoint.x, y: localPoint.y, currentParent: node.getParent(), }) } processEmbedding(e: JQuery.MouseMoveEvent, data: EventData.MovingTargetNode) { const cell = data.cell || this.cell const graph = data.graph || this.graph const options = graph.options.embedding const findParent = options.findParent let candidates = typeof findParent === 'function' ? ( FunctionExt.call(findParent, graph, { view: this, node: this.cell, }) as Cell[] ).filter((c) => { return ( Cell.isCell(c) && this.cell.id !== c.id && !c.isDescendantOf(this.cell) ) }) : graph.model.getNodesUnderNode(cell, { by: findParent as Rectangle.KeyPoint, }) // Picks the node with the highest `z` index if (options.frontOnly) { candidates = candidates.slice(-1) } let newCandidateView = null const prevCandidateView = data.candidateEmbedView const validateEmbeding = options.validate for (let i = candidates.length - 1; i >= 0; i -= 1) { const candidate = candidates[i] if (prevCandidateView && prevCandidateView.cell.id === candidate.id) { // candidate remains the same newCandidateView = prevCandidateView break } else { const view = candidate.findView(graph) as NodeView if ( FunctionExt.call(validateEmbeding, graph, { child: this.cell, parent: view.cell, childView: this, parentView: view, }) ) { // flip to the new candidate newCandidateView = view break } } } this.clearEmbedding(data) if (newCandidateView) { newCandidateView.highlight(null, { type: 'embedding' }) } data.candidateEmbedView = newCandidateView const localPoint = graph.snapToGrid(e.clientX, e.clientY) this.notify('node:embedding', { e, cell, node: cell, view: graph.findViewByCell(cell), x: localPoint.x, y: localPoint.y, currentParent: cell.getParent(), candidateParent: newCandidateView ? newCandidateView.cell : null, }) } clearEmbedding(data: EventData.MovingTargetNode) { const candidateView = data.candidateEmbedView if (candidateView) { candidateView.unhighlight(null, { type: 'embedding' }) data.candidateEmbedView = null } } finalizeEmbedding(e: JQuery.MouseUpEvent, data: EventData.MovingTargetNode) { const cell = data.cell || this.cell const graph = data.graph || this.graph const view = graph.findViewByCell(cell) const parent = cell.getParent() const candidateView = data.candidateEmbedView if (candidateView) { // Candidate view is chosen to become the parent of the node. candidateView.unhighlight(null, { type: 'embedding' }) data.candidateEmbedView = null if (parent == null || parent.id !== candidateView.cell.id) { candidateView.cell.insertChild(cell, undefined, { ui: true }) } } else if (parent) { parent.unembed(cell, { ui: true }) } graph.model.getConnectedEdges(cell, { deep: true }).forEach((edge) => { edge.updateParent({ ui: true }) }) const localPoint = graph.snapToGrid(e.clientX, e.clientY) if (view) { view.notify('node:embedded', { e, cell, x: localPoint.x, y: localPoint.y, node: cell, view: graph.findViewByCell(cell), previousParent: parent, currentParent: cell.getParent(), }) } } getDelegatedView() { let cell = this.cell let view: NodeView = this // eslint-disable-line while (view) { if (cell.isEdge()) { break } if (!cell.hasParent() || view.can('stopDelegateOnDragging')) { return view } cell = cell.getParent() as Entity view = this.graph.renderer.findViewByCell(cell) as NodeView } return null } protected startMagnetDragging( e: JQuery.MouseDownEvent, x: number, y: number, ) { if (!this.can('magnetConnectable')) { return } e.stopPropagation() const magnet = e.currentTarget const graph = this.graph this.setEventData<Partial<EventData.Magnet>>(e, { targetMagnet: magnet, }) if (graph.hook.validateMagnet(this, magnet, e)) { if (graph.options.magnetThreshold <= 0) { this.startConnectting(e, magnet, x, y) } this.setEventData<Partial<EventData.Magnet>>(e, { action: 'magnet', }) this.stopPropagation(e) } else { this.onMouseDown(e, x, y) } graph.view.delegateDragEvents(e, this) } protected startConnectting( e: JQuery.MouseDownEvent, magnet: Element, x: number, y: number, ) { this.graph.model.startBatch('add-edge') const edgeView = this.createEdgeFromMagnet(magnet, x, y) edgeView.notifyMouseDown(e, x, y) // backwards compatibility events edgeView.setEventData( e, edgeView.prepareArrowheadDragging('target', { x, y, isNewEdge: true, fallbackAction: 'remove', }), ) this.setEventData<Partial<EventData.Magnet>>(e, { edgeView }) } protected createEdgeFromMagnet(magnet: Element, x: number, y: number) { const graph = this.graph const model = graph.model const edge = graph.hook.getDefaultEdge(this, magnet) edge.setSource({ ...edge.getSource(), ...this.getEdgeTerminal(magnet, x, y, edge, 'source'), }) edge.setTarget({ ...edge.getTarget(), x, y }) edge.addTo(model, { async: false, ui: true }) return edge.findView(graph) as EdgeView } protected dragMagnet(e: JQuery.MouseMoveEvent, x: number, y: number) { const data = this.getEventData<EventData.Magnet>(e) const edgeView = data.edgeView if (edgeView) { edgeView.onMouseMove(e, x, y) this.autoScrollGraph(e.clientX, e.clientY) } else { const graph = this.graph const magnetThreshold = graph.options.magnetThreshold as any const currentTarget = this.getEventTarget(e) const targetMagnet = data.targetMagnet // magnetThreshold when the pointer leaves the magnet if (magnetThreshold === 'onleave') { if ( targetMagnet === currentTarget || targetMagnet.contains(currentTarget) ) { return } // eslint-disable-next-line no-lonely-if } else { // magnetThreshold defined as a number of movements if (graph.view.getMouseMovedCount(e) <= magnetThreshold) { return } } this.startConnectting(e as any, targetMagnet, x, y) } } protected stopMagnetDragging(e: JQuery.MouseUpEvent, x: number, y: number) { const data = this.eventData<EventData.Magnet>(e) const edgeView = data.edgeView if (edgeView) { edgeView.onMouseUp(e, x, y) this.graph.model.stopBatch('add-edge') } } protected notifyUnhandledMouseDown( e: JQuery.MouseDownEvent, x: number, y: number, ) { this.notify('node:unhandled:mousedown', { e, x, y, view: this, cell: this.cell, node: this.cell, }) } protected notifyNodeMove<Key extends keyof NodeView.EventArgs>( name: Key, e: JQuery.MouseMoveEvent | JQuery.MouseUpEvent, x: number, y: number, cell: Cell, ) { let cells = [cell] const selection = this.graph.selection.widget if (selection && selection.options.movable) { const selectedCells = this.graph.getSelectedCells() if (selectedCells.includes(cell)) { cells = selectedCells.filter((c: Cell) => c.isNode()) } } cells.forEach((c: Cell) => { this.notify(name, { e, x, y, cell: c, node: c, view: c.findView(this.graph), }) }) } protected startNodeDragging(e: JQuery.MouseDownEvent, x: number, y: number) { const targetView = this.getDelegatedView() if (targetView == null || !targetView.can('nodeMovable')) { return this.notifyUnhandledMouseDown(e, x, y) } this.setEventData<EventData.Moving>(e, { targetView, action: 'move', }) const position = Point.create(targetView.cell.getPosition()) targetView.setEventData<EventData.MovingTargetNode>(e, { moving: false, offset: position.diff(x, y), restrict: this.graph.hook.getRestrictArea(targetView), }) } protected dragNode(e: JQuery.MouseMoveEvent, x: number, y: number) { const node = this.cell const graph = this.graph const gridSize = graph.getGridSize() const data = this.getEventData<EventData.MovingTargetNode>(e) const offset = data.offset const restrict = data.restrict if (!data.moving) { data.moving = true this.addClass('node-moving') this.notifyNodeMove('node:move', e, x, y, this.cell) } this.autoScrollGraph(e.clientX, e.clientY) const posX = Util.snapToGrid(x + offset.x, gridSize) const posY = Util.snapToGrid(y + offset.y, gridSize) node.setPosition(posX, posY, { restrict, deep: true, ui: true, }) if (graph.options.embedding.enabled) { if (!data.embedding) { this.prepareEmbedding(e) data.embedding = true } this.processEmbedding(e, data) } } protected stopNodeDragging(e: JQuery.MouseUpEvent, x: number, y: number) { const data = this.getEventData<EventData.MovingTargetNode>(e) if (data.embedding) { this.finalizeEmbedding(e, data) } if (data.moving) { this.removeClass('node-moving') this.notifyNodeMove('node:moved', e, x, y, this.cell) } data.moving = false data.embedding = false } protected autoScrollGraph(x: number, y: number) { const scroller = this.graph.scroller.widget if (scroller) { scroller.autoScroll(x, y) } } // #endregion } export namespace NodeView { export interface Options extends CellView.Options {} export interface PortCache { portElement: Element portSelectors?: Markup.Selectors | null portLabelElement: Element portLabelSelectors?: Markup.Selectors | null portContentElement: Element portContentSelectors?: Markup.Selectors | null } } export namespace NodeView { interface MagnetEventArgs { magnet: Element } export interface MouseEventArgs<E> { e: E node: Node cell: Node view: NodeView } export interface PositionEventArgs<E> extends MouseEventArgs<E>, CellView.PositionEventArgs {} export interface TranslateEventArgs<E> extends PositionEventArgs<E> {} export interface ResizeEventArgs<E> extends PositionEventArgs<E> {} export interface RotateEventArgs<E> extends PositionEventArgs<E> {} export interface EventArgs { 'node:click': PositionEventArgs<JQuery.ClickEvent> 'node:dblclick': PositionEventArgs<JQuery.DoubleClickEvent> 'node:contextmenu': PositionEventArgs<JQuery.ContextMenuEvent> 'node:mousedown': PositionEventArgs<JQuery.MouseDownEvent> 'node:mousemove': PositionEventArgs<JQuery.MouseMoveEvent> 'node:mouseup': PositionEventArgs<JQuery.MouseUpEvent> 'node:mouseover': MouseEventArgs<JQuery.MouseOverEvent> 'node:mouseout': MouseEventArgs<JQuery.MouseOutEvent> 'node:mouseenter': MouseEventArgs<JQuery.MouseEnterEvent> 'node:mouseleave': MouseEventArgs<JQuery.MouseLeaveEvent> 'node:mousewheel': PositionEventArgs<JQuery.TriggeredEvent> & CellView.MouseDeltaEventArgs 'node:customevent': PositionEventArgs<JQuery.MouseDownEvent> & { name: string } 'node:unhandled:mousedown': PositionEventArgs<JQuery.MouseDownEvent> 'node:highlight': { magnet: Element view: NodeView node: Node cell: Node options: CellView.HighlightOptions } 'node:unhighlight': EventArgs['node:highlight'] 'node:magnet:click': PositionEventArgs<JQuery.MouseUpEvent> & MagnetEventArgs 'node:magnet:dblclick': PositionEventArgs<JQuery.DoubleClickEvent> & MagnetEventArgs 'node:magnet:contextmenu': PositionEventArgs<JQuery.ContextMenuEvent> & MagnetEventArgs 'node:move': TranslateEventArgs<JQuery.MouseMoveEvent> 'node:moving': TranslateEventArgs<JQuery.MouseMoveEvent> 'node:moved': TranslateEventArgs<JQuery.MouseUpEvent> 'node:resize': ResizeEventArgs<JQuery.MouseDownEvent> 'node:resizing': ResizeEventArgs<JQuery.MouseMoveEvent> 'node:resized': ResizeEventArgs<JQuery.MouseUpEvent> 'node:rotate': RotateEventArgs<JQuery.MouseDownEvent> 'node:rotating': RotateEventArgs<JQuery.MouseMoveEvent> 'node:rotated': RotateEventArgs<JQuery.MouseUpEvent> 'node:embed': PositionEventArgs<JQuery.MouseMoveEvent> & { currentParent: Node | null } 'node:embedding': PositionEventArgs<JQuery.MouseMoveEvent> & { currentParent: Node | null candidateParent: Node | null } 'node:embedded': PositionEventArgs<JQuery.MouseUpEvent> & { currentParent: Node | null previousParent: Node | null } } } export namespace NodeView { export const toStringTag = `X6.${NodeView.name}` export function isNodeView(instance: any): instance is NodeView { if (instance == null) { return false } if (instance instanceof NodeView) { return true } const tag = instance[Symbol.toStringTag] const view = instance as NodeView if ( (tag == null || tag === toStringTag) && typeof view.isNodeView === 'function' && typeof view.isEdgeView === 'function' && typeof view.confirmUpdate === 'function' && typeof view.update === 'function' && typeof view.findPortElem === 'function' && typeof view.resize === 'function' && typeof view.rotate === 'function' && typeof view.translate === 'function' ) { return true } return false } } namespace EventData { export type Mousemove = Moving | Magnet export interface Magnet { action: 'magnet' targetMagnet: Element edgeView?: EdgeView } export interface Moving { action: 'move' targetView: NodeView } export interface MovingTargetNode { moving: boolean offset: Point.PointLike restrict?: Rectangle.RectangleLike | null embedding?: boolean candidateEmbedView?: NodeView | null cell?: Node graph?: Graph } } NodeView.config({ isSvgElement: true, priority: 0, bootstrap: ['render'], actions: { view: ['render'], markup: ['render'], attrs: ['update'], size: ['resize', 'ports', 'tools'], angle: ['rotate', 'tools'], position: ['translate', 'tools'], ports: ['ports'], tools: ['tools'], }, }) NodeView.registry.register('node', NodeView, true)