UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering

1,586 lines (1,381 loc) 45.5 kB
import { Dom, disposable, FunctionExt, isModifierKeyMatch, type KeyValue, type ModifierKey, } from '../../common' import { type Point, type PointLike, Rectangle, type RectangleLike, } from '../../geometry' import type { Graph } from '../../graph' import type { CollectionAddOptions, CollectionRemoveOptions, CollectionSetOptions, Edge, Model, Node, SetOptions, } from '../../model' import { Cell, Collection, type CollectionEventArgs } from '../../model' import type { RouterData } from '../../model/edge' import { routerRegistry } from '../../registry' import { type CellView, View } from '../../view' import type { Scroller } from '../scroller' export class SelectionImpl extends View<SelectionImplEventArgs> { public readonly options: SelectionImplOptions protected readonly collection: Collection protected selectionContainer: HTMLElement protected selectionContent: HTMLElement protected boxCount: number protected boxesUpdated: boolean protected updateThrottleTimer: ReturnType<typeof setTimeout> | null = null protected isDragging: boolean = false protected batchUpdating: boolean = false // 逐帧批处理拖拽位移,降低 translate 重绘频率 protected dragRafId: number | null = null // 合并缩放/平移下的选择框刷新到每帧一次 protected transformRafId: number | null = null protected dragPendingOffset: { dx: number; dy: number } | null = null protected containerLocalOffsetX: number = 0 protected containerLocalOffsetY: number = 0 protected containerOffsetX: number = 0 protected containerOffsetY: number = 0 protected draggingPreviewMode: 'translate' | 'geometry' = 'translate' // 拖拽过程的缓存,减少每次 move 重复计算 protected translatingCache: { selectedNodes: Node[] nodeIdSet: Set<string> edgesToTranslate: Edge[] } | null = null protected movingRouterRestoreCache: KeyValue<RouterData | undefined> | null = null protected movingRouterRestoreTimer: ReturnType<typeof setTimeout> | null = null protected lastMovingTs: number | null = null protected movingDegradeActivatedTs: number | null = null private static readonly RESTORE_IDLE_TIME = 100 private static readonly RESTORE_HOLD_TIME = 150 private static readonly MIN_RESTORE_WAIT_TIME = 50 public get graph() { return this.options.graph } protected get boxClassName() { return this.prefixClassName(classNames.box) } protected get $boxes() { return Dom.children(this.container, this.boxClassName) } protected get handleOptions() { return this.options } constructor(options: SelectionImplOptions) { super() this.options = options if (this.options.model) { this.options.collection = this.options.model.collection } if (this.options.collection) { this.collection = this.options.collection } else { this.collection = new Collection([], { comparator: depthComparator, }) this.options.collection = this.collection } this.boxCount = 0 this.boxesUpdated = false this.createContainer() this.startListening() } protected startListening() { const graph = this.graph const collection = this.collection this.delegateEvents( { [`mousedown .${this.boxClassName}`]: 'onSelectionBoxMouseDown', [`touchstart .${this.boxClassName}`]: 'onSelectionBoxMouseDown', [`mousedown .${this.prefixClassName(classNames.inner)}`]: 'onSelectionContainerMouseDown', [`touchstart .${this.prefixClassName(classNames.inner)}`]: 'onSelectionContainerMouseDown', }, true, ) graph.on('scale', this.onGraphTransformed, this) graph.on('translate', this.onGraphTransformed, this) graph.model.on('updated', this.onModelUpdated, this) collection.on('added', this.onCellAdded, this) collection.on('removed', this.onCellRemoved, this) collection.on('reseted', this.onReseted, this) collection.on('updated', this.onCollectionUpdated, this) collection.on('node:change:position', this.onNodePositionChanged, this) collection.on('cell:changed', this.onCellChanged, this) } protected stopListening() { const graph = this.graph const collection = this.collection this.undelegateEvents() graph.off('scale', this.onGraphTransformed, this) graph.off('translate', this.onGraphTransformed, this) // 清理缩放/平移的 rAF 刷新与 throttleTimer if (this.transformRafId != null) { cancelAnimationFrame(this.transformRafId) this.transformRafId = null } if (this.updateThrottleTimer) { clearTimeout(this.updateThrottleTimer) this.updateThrottleTimer = null } graph.model.off('updated', this.onModelUpdated, this) collection.off('added', this.onCellAdded, this) collection.off('removed', this.onCellRemoved, this) collection.off('reseted', this.onReseted, this) collection.off('updated', this.onCollectionUpdated, this) collection.off('node:change:position', this.onNodePositionChanged, this) collection.off('cell:changed', this.onCellChanged, this) } protected onRemove() { this.stopListening() } protected onGraphTransformed() { if (this.updateThrottleTimer) { clearTimeout(this.updateThrottleTimer) this.updateThrottleTimer = null } // 使用 rAF 将多次 transform 合并为每帧一次刷新 if (this.transformRafId == null) { this.transformRafId = window.requestAnimationFrame(() => { this.transformRafId = null if (this.collection.length <= 0) { return } if (this.isDragging) { this.repositionSelectionBoxesInPlace() if (this.options.following) { this.resetContainerPosition() } else { this.syncContainerPosition() } return } this.refreshSelectionBoxes() }) } } protected onCellChanged() { this.updateSelectionBoxes() } protected translating: boolean protected onNodePositionChanged({ node, options, }: CollectionEventArgs['node:change:position']) { const { showNodeSelectionBox, pointerEvents } = this.options const { ui, selection, translateBy, snapped } = options const allowTranslating = (showNodeSelectionBox !== true || (pointerEvents && this.getPointerEventsValue(pointerEvents) === 'none')) && !this.translating && !selection const translateByUi = ui && translateBy && node.id === translateBy if (allowTranslating && (translateByUi || snapped)) { this.translating = true const current = node.position() const previous = node.previous('position') if (previous) { const dx = current.x - previous.x const dy = current.y - previous.y if (dx !== 0 || dy !== 0) { this.translateSelectedNodes(dx, dy, node, options) } } this.translating = false } } protected onModelUpdated({ removed }: CollectionEventArgs['updated']) { if (removed?.length) { this.unselect(removed) } } isEmpty() { return this.length <= 0 } isSelected(cell: Cell | string) { return this.collection.has(cell) } get length() { return this.collection.length } get cells() { return this.collection.toArray() } select(cells: Cell | Cell[], options: SelectionImplAddOptions = {}) { options.dryrun = true const items = this.filter(Array.isArray(cells) ? cells : [cells]) this.collection.add(items, options) return this } unselect(cells: Cell | Cell[], options: SelectionImplRemoveOptions = {}) { // dryrun to prevent cell be removed from graph options.dryrun = true this.collection.remove(Array.isArray(cells) ? cells : [cells], options) return this } reset(cells?: Cell | Cell[], options: SelectionImplSetOptions = {}) { if (cells) { this.batchUpdating = !!options.batch const prev = this.cells const next = this.filter(Array.isArray(cells) ? cells : [cells]) const prevMap: KeyValue<Cell> = {} const nextMap: KeyValue<Cell> = {} for (const cell of prev) { prevMap[cell.id] = cell } for (const cell of next) { nextMap[cell.id] = cell } const added: Cell[] = [] const removed: Cell[] = [] next.forEach((cell) => { if (!prevMap[cell.id]) { added.push(cell) } }) prev.forEach((cell) => { if (!nextMap[cell.id]) { removed.push(cell) } }) if (removed.length) { this.unselect(removed, { ...options, ui: true }) } if (added.length) { this.select(added, { ...options, ui: true }) } this.updateContainer() this.batchUpdating = false return this } return this.clean(options) } clean(options: SelectionImplSetOptions = {}) { if (this.length) { this.unselect(this.cells, options) } // 清理容器 transform 与位移累计 this.resetContainerPosition() this.draggingPreviewMode = 'translate' return this } setFilter(filter?: SelectionImplFilter) { this.options.filter = filter } setContent(content?: SelectionImplContent) { this.options.content = content } startSelecting(evt: Dom.MouseDownEvent) { // Flow: startSelecting => adjustSelection => stopSelecting evt = this.normalizeEvent(evt) // eslint-disable-line this.clean() let x: number let y: number const graphContainer = this.graph.container if ( evt.offsetX != null && evt.offsetY != null && graphContainer.contains(evt.target) ) { x = evt.offsetX y = evt.offsetY } else { const offset = Dom.offset(graphContainer) const scrollLeft = graphContainer.scrollLeft const scrollTop = graphContainer.scrollTop x = evt.clientX - offset.left + window.pageXOffset + scrollLeft y = evt.clientY - offset.top + window.pageYOffset + scrollTop } Dom.css(this.container, { top: y, left: x, width: 1, height: 1, }) this.setEventData<SelectingEventData>(evt, { action: 'selecting', clientX: evt.clientX, clientY: evt.clientY, offsetX: x, offsetY: y, scrollerX: 0, scrollerY: 0, moving: false, }) const client = this.graph.snapToGrid(evt.clientX, evt.clientY) this.notifyBoxEvent('box:mousedown', evt, client.x, client.y, []) this.delegateDocumentEvents(documentEvents, evt.data) } filter(cells: Cell[]) { const filter = this.options.filter return cells.filter((cell) => { if (Array.isArray(filter)) { return filter.some((item) => { if (typeof item === 'string') { return cell.shape === item } return cell.id === item.id }) } if (typeof filter === 'function') { return FunctionExt.call(filter, this.graph, cell) } return true }) } protected stopSelecting(evt: Dom.MouseUpEvent) { // 重置拖拽状态和清理定时器 this.isDragging = false this.boxesUpdated = false if (this.updateThrottleTimer) { clearTimeout(this.updateThrottleTimer) this.updateThrottleTimer = null } const graph = this.graph const eventData = this.getEventData<CommonEventData>(evt) const action = eventData.action switch (action) { case 'selecting': { const client = graph.snapToGrid(evt.clientX, evt.clientY) const rect = this.getSelectingRect() const cells = this.getCellsInArea(rect) this.reset(cells, { batch: true }) this.hideRubberband() this.notifyBoxEvent('box:mouseup', evt, client.x, client.y, cells) break } case 'translating': { const client = graph.snapToGrid(evt.clientX, evt.clientY) if (this.dragPendingOffset) { const toApply = this.dragPendingOffset this.dragPendingOffset = null this.applyDraggingPreview(toApply) } if (this.dragRafId != null) { cancelAnimationFrame(this.dragRafId) this.dragRafId = null } // 重置容器 transform 与累计偏移 this.resetContainerPosition() if (this.movingRouterRestoreTimer) { clearTimeout(this.movingRouterRestoreTimer) this.movingRouterRestoreTimer = null } this.restoreMovingRouters() this.graph.model.stopBatch('move-selection') // 清理本次拖拽缓存 this.translatingCache = null this.draggingPreviewMode = 'translate' this.notifyBoxEvent('box:mouseup', evt, client.x, client.y) this.repositionSelectionBoxesInPlace() break } default: { this.clean() break } } this.undelegateDocumentEvents() } protected onMouseUp(evt: Dom.MouseUpEvent) { const e = this.normalizeEvent(evt) const eventData = this.getEventData<CommonEventData>(e) if (eventData) { this.stopSelecting(evt) } } protected onSelectionBoxMouseDown(evt: Dom.MouseDownEvent) { this.handleSelectionMouseDown(evt, true) } protected onSelectionContainerMouseDown(evt: Dom.MouseDownEvent) { this.handleSelectionMouseDown(evt, false) } protected handleSelectionMouseDown(evt: Dom.MouseDownEvent, isBox: boolean) { evt.stopPropagation() evt.preventDefault?.() const e = this.normalizeEvent(evt) const client = this.graph.snapToGrid(e.clientX, e.clientY) // 容器内的多选切换:按下修饰键时,不拖拽,直接切换选中状态 if ( !isBox && isModifierKeyMatch(e, this.options.multipleSelectionModifiers) ) { const viewsUnderPoint = this.graph.findViewsFromPoint(client.x, client.y) const nodeView = viewsUnderPoint.find((v) => v.isNodeView()) if (nodeView) { const cell = nodeView.cell if (this.isSelected(cell)) { this.unselect(cell, { ui: true }) } else { if (this.options.multiple === false) { this.reset(cell, { ui: true }) } else { this.select(cell, { ui: true }) } } } return } if (this.options.movable) { this.startTranslating(e) } let activeView = isBox ? this.getCellViewFromElem(e.target) : null if (!activeView) { const viewsUnderPoint = this.graph .findViewsFromPoint(client.x, client.y) .filter((view) => this.isSelected(view.cell)) activeView = viewsUnderPoint[0] || null if (!activeView) { const firstSelected = this.collection.first() if (firstSelected) { activeView = this.graph.renderer.findViewByCell(firstSelected) } } } if (activeView) { this.setEventData<SelectionBoxEventData>(e, { activeView }) if (isBox) { this.notifyBoxEvent('box:mousedown', e, client.x, client.y) } this.delegateDocumentEvents(documentEvents, e.data) } } protected startTranslating(evt: Dom.MouseDownEvent) { this.graph.model.startBatch('move-selection') const client = this.graph.snapToGrid(evt.clientX, evt.clientY) this.setEventData<TranslatingEventData>(evt, { action: 'translating', clientX: client.x, clientY: client.y, originX: client.x, originY: client.y, }) this.prepareTranslatingCache() this.draggingPreviewMode = this.getDraggingPreviewMode() } private getRestrictArea(): RectangleLike | null { const restrict = this.graph.options.translating.restrict const area = typeof restrict === 'function' ? FunctionExt.call(restrict, this.graph, null) : restrict if (typeof area === 'number') { return this.graph.transform.getGraphArea().inflate(area) } if (area === true) { return this.graph.transform.getGraphArea() } return area || null } // 根据当前选择的节点构建拖拽缓存 protected prepareTranslatingCache() { const selectedNodes = this.collection .toArray() .filter((cell): cell is Node => cell.isNode()) const nodeIdSet = new Set(selectedNodes.map((n) => n.id)) const selectedEdges = this.collection .toArray() .filter((cell): cell is Edge => cell.isEdge()) const edgesToTranslateSet = new Set<Edge>() const needsTranslate = (edge: Edge) => edge.getVertices().length > 0 || !edge.getSourceCellId() || !edge.getTargetCellId() // 邻接边:仅当需要位移(有顶点或点端点)时加入缓存 this.graph.model.getEdges().forEach((edge) => { const srcId = edge.getSourceCellId() const tgtId = edge.getTargetCellId() const isConnectedToSelectedNode = (srcId != null && nodeIdSet.has(srcId)) || (tgtId != null && nodeIdSet.has(tgtId)) if (isConnectedToSelectedNode && needsTranslate(edge)) { edgesToTranslateSet.add(edge) } }) // 选中的边(不一定与选中节点相邻)也需要考虑 selectedEdges.forEach((edge) => { if (needsTranslate(edge)) { edgesToTranslateSet.add(edge) } }) this.translatingCache = { selectedNodes, nodeIdSet, edgesToTranslate: Array.from(edgesToTranslateSet), } } /** * 在移动过程中对与当前选中节点相连的边进行临时路由降级 */ protected applyMovingRouterFallback() { if (this.movingRouterRestoreCache) return const selectedNodes = this.translatingCache?.selectedNodes if (!selectedNodes || selectedNodes.length < 2) return const fallbackRaw = this.options.movingRouterFallback if (!fallbackRaw || !routerRegistry.exist(fallbackRaw)) return const fallback = { name: fallbackRaw } const restore: KeyValue<RouterData | undefined> = {} const processedEdges = new Set<string>() selectedNodes.forEach((node) => { this.graph.model.getConnectedEdges(node).forEach((edge) => { if (processedEdges.has(edge.id)) { return } processedEdges.add(edge.id) const current = edge.getRouter() restore[edge.id] = current edge.setRouter(fallback, { silent: true }) }) }) this.movingRouterRestoreCache = restore this.movingDegradeActivatedTs = Date.now() } /** * 恢复移动过程中被降级的边的原始路由: * - 如果原始路由为空则移除路由设置 * - 完成恢复后清空缓存,等待下一次移动重新降级 */ protected restoreMovingRouters() { const restore = this.movingRouterRestoreCache if (!restore) return Object.keys(restore).forEach((id) => { const edge = this.graph.getCellById(id) as Edge | null if (!edge || !edge.isEdge()) return const original = restore[id] if (original == null) { edge.removeRouter({ silent: true }) } else { edge.setRouter(original, { silent: true }) } const view = this.graph.findViewByCell(edge) if (view) { this.graph.renderer.requestViewUpdate(view, view.getFlag('update'), { async: true, }) } }) this.movingRouterRestoreCache = null } /** * 在移动停止后延迟恢复路由,避免连线抖动: * - `idle`:距离上次移动的空闲时间必须超过 100ms * - `hold`:降级保持时间必须超过 150ms * - 若条件未满足则按最小等待时间再次调度恢复 */ protected scheduleMovingRouterRestoreThrottle() { if (this.movingRouterRestoreTimer) { clearTimeout(this.movingRouterRestoreTimer) this.movingRouterRestoreTimer = null } this.movingRouterRestoreTimer = setTimeout(() => { const now = Date.now() const lastMove = this.lastMovingTs || 0 const idle = now - lastMove const hold = this.movingDegradeActivatedTs != null ? now - this.movingDegradeActivatedTs : Infinity if ( idle < SelectionImpl.RESTORE_IDLE_TIME || hold < SelectionImpl.RESTORE_HOLD_TIME ) { const wait = Math.max( SelectionImpl.RESTORE_IDLE_TIME - idle, SelectionImpl.RESTORE_HOLD_TIME - hold, SelectionImpl.MIN_RESTORE_WAIT_TIME, ) this.movingRouterRestoreTimer = setTimeout(() => { this.movingRouterRestoreTimer = null this.restoreMovingRouters() }, wait) return } this.movingRouterRestoreTimer = null this.restoreMovingRouters() }, SelectionImpl.RESTORE_IDLE_TIME) } protected getSelectionOffset(client: Point, data: TranslatingEventData) { let dx = client.x - data.clientX let dy = client.y - data.clientY const restrict = this.getRestrictArea() if (restrict) { const cells = this.collection.toArray() const totalBBox = Cell.getCellsBBox(cells, { deep: true }) || Rectangle.create() const minDx = restrict.x - totalBBox.x const minDy = restrict.y - totalBBox.y const maxDx = restrict.x + restrict.width - (totalBBox.x + totalBBox.width) const maxDy = restrict.y + restrict.height - (totalBBox.y + totalBBox.height) if (dx < minDx) { dx = minDx } if (dy < minDy) { dy = minDy } if (maxDx < dx) { dx = maxDx } if (maxDy < dy) { dy = maxDy } if (!this.options.following) { const offsetX = client.x - data.originX const offsetY = client.y - data.originY dx = offsetX <= minDx || offsetX >= maxDx ? 0 : dx dy = offsetY <= minDy || offsetY >= maxDy ? 0 : dy } } return { dx, dy, } } protected updateSelectedNodesPosition(offset: { dx: number; dy: number }) { if (offset.dx === 0 && offset.dy === 0) { return } // 合并偏移并在下一帧统一应用,减少高频重绘 if (this.dragPendingOffset) { this.dragPendingOffset.dx += offset.dx this.dragPendingOffset.dy += offset.dy } else { this.dragPendingOffset = { dx: offset.dx, dy: offset.dy } } if (this.dragRafId == null) { this.dragRafId = requestAnimationFrame(() => { const toApply = this.dragPendingOffset || { dx: 0, dy: 0 } this.dragPendingOffset = null this.dragRafId = null this.applyDraggingPreview(toApply) this.boxesUpdated = true this.isDragging = true }) } } protected autoScrollGraph( x: number, y: number, ): { scrollerX: number; scrollerY: number } { const scroller = this.graph.getPlugin<Scroller>('scroller') if (scroller?.autoScroll) { return scroller.autoScroll(x, y) } return { scrollerX: 0, scrollerY: 0 } } protected adjustSelection(evt: Dom.MouseMoveEvent) { const e = this.normalizeEvent(evt) const eventData = this.getEventData<CommonEventData>(e) const action = eventData.action switch (action) { case 'selecting': { const data = eventData as SelectingEventData if (data.moving !== true) { Dom.appendTo(this.container, this.graph.container) this.showRubberband() data.moving = true } const { scrollerX, scrollerY } = this.autoScrollGraph( e.clientX, e.clientY, ) data.scrollerX += scrollerX data.scrollerY += scrollerY const dx = e.clientX - data.clientX + data.scrollerX const dy = e.clientY - data.clientY + data.scrollerY Dom.css(this.container, { left: dx < 0 ? data.offsetX + dx : data.offsetX, top: dy < 0 ? data.offsetY + dy : data.offsetY, width: Math.abs(dx), height: Math.abs(dy), }) const client = this.graph.snapToGrid(e.clientX, e.clientY) const rect = this.getSelectingRect() const cells = this.getCellsInArea(rect) this.notifyBoxEvent('box:mousemove', evt, client.x, client.y, cells) break } case 'translating': { this.isDragging = true const client = this.graph.snapToGrid(e.clientX, e.clientY) const data = eventData as TranslatingEventData const offset = this.getSelectionOffset(client, data) if (this.options.following) { this.updateSelectedNodesPosition(offset) } else { this.updateContainerPosition(offset) } if (offset.dx) { data.clientX = client.x } if (offset.dy) { data.clientY = client.y } if (offset.dx !== 0 || offset.dy !== 0) { this.lastMovingTs = Date.now() this.applyMovingRouterFallback() this.scheduleMovingRouterRestoreThrottle() } this.notifyBoxEvent('box:mousemove', evt, client.x, client.y) break } default: break } this.boxesUpdated = false } protected translateSelectedNodes( dx: number, dy: number, exclude?: Cell, otherOptions?: KeyValue, ) { const map: { [id: string]: boolean } = {} const excluded: Cell[] = [] if (exclude) { map[exclude.id] = true } this.collection.toArray().forEach((cell) => { cell.getDescendants({ deep: true }).forEach((child) => { map[child.id] = true }) }) if (otherOptions?.translateBy) { const currentCell = this.graph.getCellById(otherOptions.translateBy) if (currentCell) { map[currentCell.id] = true currentCell.getDescendants({ deep: true }).forEach((child) => { map[child.id] = true }) excluded.push(currentCell) } } const options = { ...otherOptions, selection: this.cid, exclude: excluded, } // 移动选中的节点,避免重复和嵌套 const cachedSelectedNodes = this.translatingCache?.selectedNodes const selectedNodes = ( cachedSelectedNodes ?? (this.collection.toArray().filter((cell) => cell.isNode()) as Node[]) ).filter((node) => !map[node.id]) selectedNodes.forEach((node) => { node.translate(dx, dy, options) }) // 边移动缓存:仅移动需要位移的边(有顶点或点端点) const cachedEdges = this.translatingCache?.edgesToTranslate const edgesToTranslate = new Set<Edge>() if (cachedEdges) { cachedEdges.forEach((edge) => { edgesToTranslate.add(edge) }) } else { const selectedNodeIdSet = new Set(selectedNodes.map((n) => n.id)) this.graph.model.getEdges().forEach((edge) => { const srcId = edge.getSourceCellId() const tgtId = edge.getTargetCellId() const srcSelected = srcId ? selectedNodeIdSet.has(srcId) : false const tgtSelected = tgtId ? selectedNodeIdSet.has(tgtId) : false if (srcSelected || tgtSelected) { const hasVertices = edge.getVertices().length > 0 const pointEndpoint = !srcId || !tgtId if (hasVertices || pointEndpoint) { edgesToTranslate.add(edge) } } }) } // 若选择了边(仅边、无节点),确保其也被移动(过滤无顶点且两端为节点的情况) const selectedEdges = this.collection .toArray() .filter((cell): cell is Edge => cell.isEdge() && !map[cell.id]) selectedEdges.forEach((edge) => { const hasVertices = edge.getVertices().length > 0 const pointEndpoint = !edge.getSourceCellId() || !edge.getTargetCellId() if (hasVertices || pointEndpoint) { edgesToTranslate.add(edge) } }) edgesToTranslate.forEach((edge) => { edge.translate(dx, dy, options) }) } protected getCellViewsInArea(rect: Rectangle) { const graph = this.graph const options = { strict: this.options.strict, } let views: CellView[] = [] if (this.options.rubberNode) { views = views.concat( graph.model .getNodesInArea(rect, options) .map((node) => graph.renderer.findViewByCell(node)) .filter((view) => view != null) as CellView[], ) } if (this.options.rubberEdge) { views = views.concat( graph.model .getEdgesInArea(rect, options) .map((edge) => graph.renderer.findViewByCell(edge)) .filter((view) => view != null) as CellView[], ) } return views } protected getCellsInArea(rect: Rectangle) { return this.filter(this.getCellViewsInArea(rect).map((view) => view.cell)) } protected getSelectingRect() { let width = Dom.width(this.container) let height = Dom.height(this.container) const offset = Dom.offset(this.container) const origin = this.graph.pageToLocal(offset.left, offset.top) const scale = this.graph.transform.getScale() width /= scale.sx height /= scale.sy return new Rectangle(origin.x, origin.y, width, height) } protected getBoxEventCells( cells?: Cell[], activeView: CellView | null = null, ) { const nodes: Node[] = [] const edges: Edge[] = [] let view = activeView ;(cells ?? this.cells).forEach((cell) => { const current = this.graph.getCellById(cell.id) if (!current) { return } if (!view) { view = this.graph.renderer.findViewByCell(current) } if (current.isNode()) { nodes.push(current) } else if (current.isEdge()) { edges.push(current) } }) return { view, cell: view?.cell ?? null, nodes, edges, } } protected notifyBoxEvent< K extends keyof SelectionImplBoxEventArgsRecord, T extends Dom.EventObject, >(name: K, e: T, x: number, y: number, cells?: Cell[]) { const activeView = this.getEventData<SelectionBoxEventData | null>(e)?.activeView ?? null const { view, cell, nodes, edges } = this.getBoxEventCells( cells, activeView, ) this.trigger(name, { e, view, x, y, cell, nodes, edges }) } protected getSelectedClassName(cell: Cell) { return this.prefixClassName(`${cell.isNode() ? 'node' : 'edge'}-selected`) } protected addCellSelectedClassName(cell: Cell) { const view = this.graph.renderer.findViewByCell(cell) if (view) { view.addClass(this.getSelectedClassName(cell)) } } protected removeCellUnSelectedClassName(cell: Cell) { const view = this.graph.renderer.findViewByCell(cell) if (view) { view.removeClass(this.getSelectedClassName(cell)) } } protected destroySelectionBox(cell: Cell) { this.removeCellUnSelectedClassName(cell) if (this.canShowSelectionBox(cell)) { Dom.remove(this.container.querySelector(`[data-cell-id="${cell.id}"]`)) if (this.$boxes.length === 0) { this.hide() } this.boxCount = Math.max(0, this.boxCount - 1) } } protected destroyAllSelectionBoxes(cells: Cell[]) { cells.forEach((cell) => { this.removeCellUnSelectedClassName(cell) }) this.hide() Dom.remove(this.$boxes) this.boxCount = 0 } hide() { Dom.removeClass(this.container, this.prefixClassName(classNames.rubberband)) Dom.removeClass(this.container, this.prefixClassName(classNames.selected)) } protected showRubberband() { Dom.addClass(this.container, this.prefixClassName(classNames.rubberband)) } protected hideRubberband() { Dom.removeClass(this.container, this.prefixClassName(classNames.rubberband)) } protected showSelected() { Dom.removeAttribute(this.container, 'style') Dom.addClass(this.container, this.prefixClassName(classNames.selected)) } protected createContainer() { this.container = document.createElement('div') Dom.addClass(this.container, this.prefixClassName(classNames.root)) if (this.options.className) { Dom.addClass(this.container, this.options.className) } Dom.css(this.container, { willChange: 'transform', }) this.selectionContainer = document.createElement('div') Dom.addClass( this.selectionContainer, this.prefixClassName(classNames.inner), ) this.selectionContent = document.createElement('div') Dom.addClass( this.selectionContent, this.prefixClassName(classNames.content), ) Dom.append(this.selectionContainer, this.selectionContent) Dom.attr( this.selectionContainer, 'data-selection-length', this.collection.length, ) Dom.prepend(this.container, this.selectionContainer) } protected getDraggingPreviewMode() { if (!this.options.following) { return 'translate' } const hasVisibleEdgeSelectionBox = this.collection .toArray() .some((cell) => cell.isEdge() && this.canShowSelectionBox(cell)) return hasVisibleEdgeSelectionBox ? 'geometry' : 'translate' } protected applyDraggingPreview(offset: { dx: number; dy: number }) { if (offset.dx === 0 && offset.dy === 0) { return } if (this.options.following) { this.translateSelectedNodes(offset.dx, offset.dy) if (this.draggingPreviewMode === 'geometry') { this.repositionSelectionBoxesInPlace() this.resetContainerPosition() return } } this.updateContainerPosition(offset) } protected resetContainerPosition() { this.containerLocalOffsetX = 0 this.containerLocalOffsetY = 0 this.containerOffsetX = 0 this.containerOffsetY = 0 Dom.css(this.container, 'transform', '') } protected syncContainerPosition() { const origin = this.graph.coord.localToGraphPoint(0, 0) const offset = this.graph.coord.localToGraphPoint( this.containerLocalOffsetX, this.containerLocalOffsetY, ) this.containerOffsetX = offset.x - origin.x this.containerOffsetY = offset.y - origin.y if (this.containerOffsetX === 0 && this.containerOffsetY === 0) { Dom.css(this.container, 'transform', '') return } Dom.css( this.container, 'transform', `translate3d(${this.containerOffsetX}px, ${this.containerOffsetY}px, 0)`, ) } protected updateContainerPosition(offset: { dx: number; dy: number }) { if (offset.dx || offset.dy) { this.containerLocalOffsetX += offset.dx this.containerLocalOffsetY += offset.dy // 使用 transform,避免频繁修改 left/top this.syncContainerPosition() } } protected updateContainer() { const origin = { x: Infinity, y: Infinity } const corner = { x: 0, y: 0 } const cells = this.collection .toArray() .filter((cell) => this.canShowSelectionBox(cell)) cells.forEach((cell) => { const view = this.graph.renderer.findViewByCell(cell) if (view) { const bbox = view.getBBox({ useCellGeometry: true, }) origin.x = Math.min(origin.x, bbox.x) origin.y = Math.min(origin.y, bbox.y) corner.x = Math.max(corner.x, bbox.x + bbox.width) corner.y = Math.max(corner.y, bbox.y + bbox.height) } }) Dom.css(this.selectionContainer, { position: 'absolute', pointerEvents: this.options.movable ? 'auto' : 'none', cursor: this.options.movable ? 'move' : 'default', left: origin.x, top: origin.y, width: corner.x - origin.x, height: corner.y - origin.y, }) Dom.attr( this.selectionContainer, 'data-selection-length', this.collection.length, ) const boxContent = this.options.content if (boxContent) { if (typeof boxContent === 'function') { const content = FunctionExt.call( boxContent, this.graph, this, this.selectionContent, ) if (content) { this.selectionContent.innerHTML = content } } else { this.selectionContent.innerHTML = boxContent } } if (this.collection.length > 0 && !this.container.parentNode) { Dom.appendTo(this.container, this.graph.container) } else if (this.collection.length <= 0 && this.container.parentNode) { this.container.parentNode.removeChild(this.container) } } protected canShowSelectionBox(cell: Cell) { return ( (cell.isNode() && this.options.showNodeSelectionBox === true) || (cell.isEdge() && this.options.showEdgeSelectionBox === true) ) } protected getPointerEventsValue( pointerEvents: 'none' | 'auto' | ((cells: Cell[]) => 'none' | 'auto'), ) { return typeof pointerEvents === 'string' ? pointerEvents : pointerEvents(this.cells) } protected createSelectionBox(cell: Cell) { this.addCellSelectedClassName(cell) if (this.canShowSelectionBox(cell)) { const view = this.graph.renderer.findViewByCell(cell) if (view) { const bbox = view.getBBox({ useCellGeometry: true, }) const className = this.boxClassName const box = document.createElement('div') const pointerEvents = this.options.pointerEvents Dom.addClass(box, className) Dom.addClass(box, `${className}-${cell.isNode() ? 'node' : 'edge'}`) Dom.attr(box, 'data-cell-id', cell.id) Dom.css(box, { position: 'absolute', left: bbox.x, top: bbox.y, width: bbox.width, height: bbox.height, pointerEvents: pointerEvents ? this.getPointerEventsValue(pointerEvents) : 'auto', }) Dom.appendTo(box, this.container) this.showSelected() this.boxCount += 1 } } } protected updateSelectionBoxes() { if (this.collection.length > 0) { if (this.isDragging) { return } if (this.updateThrottleTimer) { clearTimeout(this.updateThrottleTimer) } // 节流:限制更新频率到60fps this.updateThrottleTimer = setTimeout(() => { this.refreshSelectionBoxes() this.updateThrottleTimer = null }, 16) } } protected refreshSelectionBoxes() { Dom.remove(this.$boxes) this.boxCount = 0 this.collection.toArray().forEach((cell) => { this.createSelectionBox(cell) }) this.updateContainer() this.boxesUpdated = true } // 按当前视图几何同步每个选择框的位置与尺寸 protected repositionSelectionBoxesInPlace() { const boxes = this.$boxes if (boxes.length === 0) { this.refreshSelectionBoxes() return } for (const elem of boxes) { const id = elem.getAttribute('data-cell-id') if (!id) continue const cell = this.collection.get(id) if (!cell) continue const view = this.graph.renderer.findViewByCell(cell) if (!view) continue const bbox = view.getBBox({ useCellGeometry: true }) Dom.css(elem, { left: bbox.x, top: bbox.y, width: bbox.width, height: bbox.height, }) } this.updateContainer() this.boxesUpdated = true } protected getCellViewFromElem(elem: Element) { const id = elem.getAttribute('data-cell-id') if (id) { const cell = this.collection.get(id) if (cell) { return this.graph.renderer.findViewByCell(cell) } } return null } protected onCellRemoved({ cell }: CollectionEventArgs['removed']) { this.destroySelectionBox(cell) if (!this.batchUpdating) this.updateContainer() } protected onReseted({ previous, current }: CollectionEventArgs['reseted']) { this.destroyAllSelectionBoxes(previous) current.forEach((cell) => { this.listenCellRemoveEvent(cell) this.createSelectionBox(cell) }) this.updateContainer() } protected onCellAdded({ cell }: CollectionEventArgs['added']) { // The collection do not known the cell was removed when cell was // removed by interaction(such as, by "delete" shortcut), so we should // manually listen to cell's remove event. this.listenCellRemoveEvent(cell) this.createSelectionBox(cell) if (!this.batchUpdating) this.updateContainer() } protected listenCellRemoveEvent(cell: Cell) { cell.off('removed', this.onCellRemoved, this) cell.on('removed', this.onCellRemoved, this) } protected onCollectionUpdated({ added, removed, options, }: CollectionEventArgs['updated']) { added.forEach((cell) => { this.trigger('cell:selected', { cell, options }) if (cell.isNode()) { this.trigger('node:selected', { cell, options, node: cell }) } else if (cell.isEdge()) { this.trigger('edge:selected', { cell, options, edge: cell }) } }) removed.forEach((cell) => { this.trigger('cell:unselected', { cell, options }) if (cell.isNode()) { this.trigger('node:unselected', { cell, options, node: cell }) } else if (cell.isEdge()) { this.trigger('edge:unselected', { cell, options, edge: cell }) } }) const args = { added, removed, options, selected: this.cells.filter((cell) => !!this.graph.getCellById(cell.id)), } this.trigger('selection:changed', args) } // #endregion @disposable() dispose() { this.clean() this.remove() this.off() } } type SelectionEventType = 'leftMouseDown' | 'mouseWheelDown' export interface SelectionImplCommonOptions { model?: Model collection?: Collection className?: string strict?: boolean filter?: SelectionImplFilter modifiers?: string | ModifierKey[] | null multiple?: boolean multipleSelectionModifiers?: string | ModifierKey[] | null selectCellOnMoved?: boolean selectNodeOnMoved?: boolean selectEdgeOnMoved?: boolean showEdgeSelectionBox?: boolean showNodeSelectionBox?: boolean movable?: boolean following?: boolean content?: SelectionImplContent // Can select node or edge when rubberband rubberband?: boolean rubberNode?: boolean rubberEdge?: boolean // Whether to respond event on the selectionBox pointerEvents?: 'none' | 'auto' | ((cells: Cell[]) => 'none' | 'auto') // with which mouse button the selection can be started eventTypes?: SelectionEventType[] movingRouterFallback?: string } export interface SelectionImplOptions extends SelectionImplCommonOptions { graph: Graph } export type SelectionImplContent = | null | false | string | (( this: Graph, selection: SelectionImpl, contentElement: HTMLElement, ) => string) export type SelectionImplFilter = | null | (string | { id: string })[] | ((this: Graph, cell: Cell) => boolean) export interface SelectionImplSetOptions extends CollectionSetOptions { batch?: boolean } export interface SelectionImplAddOptions extends CollectionAddOptions {} export interface SelectionImplRemoveOptions extends CollectionRemoveOptions {} interface BaseSelectionBoxEventArgs<T> { e: T view: CellView | null cell: Cell | null x: number y: number nodes: Node[] edges: Edge[] } export interface SelectionImplBoxEventArgsRecord { 'box:mousedown': BaseSelectionBoxEventArgs<Dom.MouseDownEvent> 'box:mousemove': BaseSelectionBoxEventArgs<Dom.MouseMoveEvent> 'box:mouseup': BaseSelectionBoxEventArgs<Dom.MouseUpEvent> } export interface SelectionImplEventArgsRecord { 'cell:selected': { cell: Cell; options: SetOptions } 'node:selected': { cell: Cell; node: Node; options: SetOptions } 'edge:selected': { cell: Cell; edge: Edge; options: SetOptions } 'cell:unselected': { cell: Cell; options: SetOptions } 'node:unselected': { cell: Cell; node: Node; options: SetOptions } 'edge:unselected': { cell: Cell; edge: Edge; options: SetOptions } 'selection:changed': { added: Cell[] removed: Cell[] selected: Cell[] options: SetOptions } } export interface SelectionImplEventArgs extends SelectionImplBoxEventArgsRecord, SelectionImplEventArgsRecord {} // private // ------- const baseClassName = 'widget-selection' export const classNames = { root: baseClassName, inner: `${baseClassName}-inner`, box: `${baseClassName}-box`, content: `${baseClassName}-content`, rubberband: `${baseClassName}-rubberband`, selected: `${baseClassName}-selected`, } export const documentEvents = { mousemove: 'adjustSelection', touchmove: 'adjustSelection', mouseup: 'onMouseUp', touchend: 'onMouseUp', touchcancel: 'onMouseUp', } export function depthComparator(cell: Cell) { return cell.getAncestors().length } export interface CommonEventData { action: 'selecting' | 'translating' } export interface SelectingEventData extends CommonEventData { action: 'selecting' moving?: boolean clientX: number clientY: number offsetX: number offsetY: number scrollerX: number scrollerY: number } export interface TranslatingEventData extends CommonEventData { action: 'translating' clientX: number clientY: number originX: number originY: number } export interface SelectionBoxEventData { activeView: CellView } export interface RotationEventData { rotated?: boolean center: PointLike start: number angles: { [id: string]: number } } export interface ResizingEventData { resized?: boolean bbox: Rectangle cells: Cell[] minWidth: number minHeight: number }