UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering

531 lines (459 loc) 14.2 kB
import { alignPoint } from 'dom-align' import { CssLoader, Dom, disposable, FunctionExt } from '../../common' import { DocumentEvents } from '../../constants' import { type Point, type PointLike, Rectangle, snapToGrid, } from '../../geometry' import { type EventArgs, Graph, type GraphPlugin, type Options, } from '../../graph' import type { CellBaseEventArgs, Node } from '../../model' import { type NodeView, View } from '../../view' import type { Scroller } from '../scroller' import type { Snapline } from '../snapline' import { content } from './style/raw' export interface GetDragNodeOptions { sourceNode: Node targetGraph: Graph draggingGraph: Graph } export interface GetDropNodeOptions extends GetDragNodeOptions { draggingNode: Node } export interface ValidateNodeOptions extends GetDropNodeOptions { droppingNode: Node } export interface DndOptions { target: Graph /** * Should scale the dragging node or not. */ scaled?: boolean delegateGraphOptions?: Options draggingContainer?: HTMLElement /** * dnd tool box container. */ dndContainer?: HTMLElement getDragNode: (sourceNode: Node, options: GetDragNodeOptions) => Node getDropNode: (draggingNode: Node, options: GetDropNodeOptions) => Node validateNode?: ( droppingNode: Node, options: ValidateNodeOptions, ) => boolean | Promise<boolean> } export const DndDefaults: Partial<DndOptions> = { // animation: false, getDragNode: (sourceNode) => sourceNode.clone(), getDropNode: (draggingNode) => draggingNode.clone(), } export class Dnd extends View implements GraphPlugin { public name = 'dnd' protected sourceNode: Node | null protected draggingNode: Node | null protected draggingView: NodeView | null protected draggingBBox: Rectangle protected geometryBBox: Rectangle protected candidateEmbedView: NodeView | null protected delta: Point | null protected padding: number | null protected snapOffset: PointLike | null public options: DndOptions public draggingGraph: Graph protected get targetScroller() { const target = this.options.target const scroller = target.getPlugin<Scroller>('scroller') return scroller } protected get targetGraph() { return this.options.target } protected get targetModel() { return this.targetGraph.model } protected get snapline() { const target = this.options.target const snapline = target.getPlugin<Snapline>('snapline') return snapline } constructor(options: Partial<DndOptions> & { target: Graph }) { super() this.options = { ...DndDefaults, ...options, } as DndOptions this.init() } init() { CssLoader.ensure(this.name, content) this.container = document.createElement('div') Dom.addClass(this.container, this.prefixClassName('widget-dnd')) this.draggingGraph = new Graph({ ...this.options.delegateGraphOptions, container: document.createElement('div'), width: 1, height: 1, async: false, }) Dom.append(this.container, this.draggingGraph.container) } start(node: Node, evt: Dom.MouseDownEvent | MouseEvent) { const e = evt as Dom.MouseDownEvent e.preventDefault() this.targetModel.startBatch('dnd') Dom.addClass(this.container, 'dragging') Dom.appendTo( this.container, this.options.draggingContainer || document.body, ) this.sourceNode = node this.prepareDragging(node, e.clientX, e.clientY) const local = this.updateNodePosition(e.clientX, e.clientY) if (this.isSnaplineEnabled()) { this.snapline.captureCursorOffset({ e, node, cell: node, view: this.draggingView, x: local.x, y: local.y, }) this.draggingNode?.on('change:position', this.snap, this) } this.delegateDocumentEvents(DocumentEvents, e.data) } protected isSnaplineEnabled() { return this.snapline?.isEnabled() } protected prepareDragging( sourceNode: Node, clientX: number, clientY: number, ) { const draggingGraph = this.draggingGraph const draggingModel = draggingGraph.model const draggingNode = this.options.getDragNode(sourceNode, { sourceNode, draggingGraph, targetGraph: this.targetGraph, }) draggingNode.position(0, 0) let padding = 5 if (this.isSnaplineEnabled()) { padding += this.snapline.options.tolerance || 0 } if (this.isSnaplineEnabled() || this.options.scaled) { const scale = this.targetGraph.transform.getScale() draggingGraph.scale(scale.sx, scale.sy) padding *= Math.max(scale.sx, scale.sy) } else { draggingGraph.scale(1, 1) } this.clearDragging() // if (this.options.animation) { // this.$container.stop(true, true) // } draggingModel.resetCells([draggingNode]) const delegateView = draggingGraph.findViewByCell(draggingNode) as NodeView delegateView.undelegateEvents() delegateView.cell.off('changed') draggingGraph.fitToContent({ padding, allowNewOrigin: 'any', useCellGeometry: false, }) const bbox = delegateView.getBBox() this.geometryBBox = delegateView.getBBox({ useCellGeometry: true }) this.delta = this.geometryBBox.getTopLeft().diff(bbox.getTopLeft()) this.draggingNode = draggingNode this.draggingView = delegateView this.draggingBBox = draggingNode.getBBox() this.padding = padding this.updateGraphPosition(clientX, clientY) } protected updateGraphPosition(clientX: number, clientY: number) { const delta = this.delta const nodeBBox = this.geometryBBox const padding = this.padding || 5 const offset = { left: clientX - delta.x - nodeBBox.width / 2 - padding, top: clientY - delta.y - nodeBBox.height / 2 - padding, } if (this.draggingGraph) { alignPoint( this.container, { clientX: offset.left, clientY: offset.top, }, { points: ['tl'], }, ) } } protected updateNodePosition(x: number, y: number) { const local = this.targetGraph.clientToLocal(x, y) const bbox = this.draggingBBox if (bbox) { local.x -= bbox.width / 2 local.y -= bbox.height / 2 this.draggingNode!.position(local.x, local.y) } return local } protected snap({ cell, current, options, }: CellBaseEventArgs['change:position']) { const node = cell as Node if (options.snapped) { const bbox = this.draggingBBox node.position(bbox.x + options.tx, bbox.y + options.ty, { silent: true }) this.draggingView!.translate() node.position(current!.x, current!.y, { silent: true }) this.snapOffset = { x: options.tx, y: options.ty, } } else { this.snapOffset = null } } protected onMouseMove(evt: Dom.MouseMoveEvent) { this.onDragging(evt) } protected onMouseUp(evt: Dom.MouseUpEvent) { this.onDragEnd(evt) } protected onDragging(evt: Dom.MouseMoveEvent) { const draggingView = this.draggingView if (draggingView) { evt.preventDefault() const e = this.normalizeEvent(evt) const clientX = e.clientX const clientY = e.clientY this.updateGraphPosition(clientX, clientY) const local = this.updateNodePosition(clientX, clientY) const embeddingMode = this.targetGraph.options.embedding.enabled const isValidArea = (embeddingMode || this.isSnaplineEnabled()) && this.isInsideValidArea({ x: clientX, y: clientY, }) if (embeddingMode) { draggingView.setEventData(e, { graph: this.targetGraph, candidateEmbedView: this.candidateEmbedView, }) const data = draggingView.getEventData<any>(e) if (isValidArea) { draggingView.processEmbedding(e, data) } else { draggingView.clearEmbedding(data) } this.candidateEmbedView = data.candidateEmbedView } // update snapline if (this.isSnaplineEnabled()) { if (isValidArea) { this.snapline.snapOnMoving({ e, view: draggingView!, x: local.x, y: local.y, } as EventArgs['node:mousemove']) } else { this.snapline.hide() } } } } protected onDragEnd(evt: Dom.MouseUpEvent) { const draggingNode = this.draggingNode if (draggingNode) { const e = this.normalizeEvent(evt) const draggingView = this.draggingView const draggingBBox = this.draggingBBox const snapOffset = this.snapOffset let x = draggingBBox.x let y = draggingBBox.y if (snapOffset) { x += snapOffset.x y += snapOffset.y } draggingNode.position(x, y, { silent: true }) const ret = this.drop(draggingNode, { x: e.clientX, y: e.clientY }) const callback = (node: null | Node) => { if (node) { this.onDropped(draggingNode) if (this.targetGraph.options.embedding.enabled && draggingView) { draggingView.setEventData(e, { cell: node, graph: this.targetGraph, candidateEmbedView: this.candidateEmbedView, }) draggingView.finalizeEmbedding(e, draggingView.getEventData<any>(e)) } } else { this.onDropInvalid() } this.candidateEmbedView = null this.targetModel.stopBatch('dnd') } if (FunctionExt.isAsync(ret)) { // stop dragging this.undelegateDocumentEvents() ret.then(callback) // eslint-disable-line } else { callback(ret) } } } protected clearDragging() { if (this.draggingNode) { this.sourceNode = null this.draggingNode.remove() this.draggingNode = null this.draggingView = null this.delta = null this.padding = null this.snapOffset = null this.undelegateDocumentEvents() } } protected onDropped(draggingNode: Node) { if (this.draggingNode === draggingNode) { this.clearDragging() Dom.removeClass(this.container, 'dragging') Dom.remove(this.container) } } protected onDropInvalid() { const draggingNode = this.draggingNode if (draggingNode) { this.onDropped(draggingNode) // todo // const anim = this.options.animation // if (anim) { // const duration = (typeof anim === 'object' && anim.duration) || 150 // const easing = (typeof anim === 'object' && anim.easing) || 'swing' // this.draggingView = null // this.$container.animate(this.originOffset!, duration, easing, () => // this.onDropped(draggingNode), // ) // } else { // this.onDropped(draggingNode) // } } } protected isInsideValidArea(p: PointLike) { let targetRect: Rectangle let dndRect: Rectangle | null = null const targetGraph = this.targetGraph const targetScroller = this.targetScroller if (this.options.dndContainer) { dndRect = this.getDropArea(this.options.dndContainer) } const isInsideDndRect = dndRect?.containsPoint(p) if (targetScroller) { if (targetScroller.options.autoResize) { targetRect = this.getDropArea(targetScroller.container) } else { const outter = this.getDropArea(targetScroller.container) targetRect = this.getDropArea(targetGraph.container).intersectsWithRect( outter, )! } } else { targetRect = this.getDropArea(targetGraph.container) } return !isInsideDndRect && targetRect && targetRect.containsPoint(p) } protected getDropArea(elem: Element) { const offset = Dom.offset(elem) const scrollTop = document.body.scrollTop || document.documentElement.scrollTop const scrollLeft = document.body.scrollLeft || document.documentElement.scrollLeft return Rectangle.create({ x: offset.left + parseInt(Dom.css(elem, 'border-left-width')!, 10) - scrollLeft, y: offset.top + parseInt(Dom.css(elem, 'border-top-width'), 10) - scrollTop, width: elem.clientWidth, height: elem.clientHeight, }) } protected drop(draggingNode: Node, pos: PointLike) { if (this.isInsideValidArea(pos)) { const targetGraph = this.targetGraph const targetModel = targetGraph.model const local = targetGraph.clientToLocal(pos) const sourceNode = this.sourceNode const droppingNode = this.options.getDropNode(draggingNode, { sourceNode, draggingNode, targetGraph: this.targetGraph, draggingGraph: this.draggingGraph, }) const bbox = droppingNode.getBBox() local.x += bbox.x - bbox.width / 2 local.y += bbox.y - bbox.height / 2 const gridSize = this.snapOffset ? 1 : targetGraph.getGridSize() droppingNode.position( snapToGrid(local.x, gridSize), snapToGrid(local.y, gridSize), ) droppingNode.removeZIndex() const validateNode = this.options.validateNode const ret = validateNode ? validateNode(droppingNode, { sourceNode, draggingNode, droppingNode, targetGraph, draggingGraph: this.draggingGraph, }) : true if (typeof ret === 'boolean') { if (ret) { targetModel.addCell(droppingNode, { stencil: this.cid }) return droppingNode } return null } return FunctionExt.toDeferredBoolean(ret).then((valid) => { if (valid) { targetModel.addCell(droppingNode, { stencil: this.cid }) return droppingNode } return null }) } return null } protected onRemove() { if (this.draggingGraph) { this.draggingGraph.view.remove() this.draggingGraph.dispose() } } @disposable() dispose() { this.remove() CssLoader.clean(this.name) } }