diff --git a/examples/src/pages/case/bpmn.tsx b/examples/src/pages/case/bpmn.tsx index 9fc174a17aa..51a26f49813 100644 --- a/examples/src/pages/case/bpmn.tsx +++ b/examples/src/pages/case/bpmn.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react' -import { Graph, Cell } from '@antv/x6' +import { Graph, Cell, Selection } from '@antv/x6' import '../index.less' Graph.registerNode( @@ -226,6 +226,16 @@ export const CaseBpmnExample: React.FC = () => { }, }) + graph.use( + new Selection({ + multiple: true, + rubberband: true, + showNodeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, + }), + ) + const cells: Cell[] = [] data.forEach((item) => { if (item.shape === 'bpmn-edge') { diff --git a/examples/src/pages/case/class.tsx b/examples/src/pages/case/class.tsx index efcd94e8a43..3c8988a133e 100644 --- a/examples/src/pages/case/class.tsx +++ b/examples/src/pages/case/class.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react' -import { Graph, Cell, ObjectExt } from '@antv/x6' +import { Graph, Cell, ObjectExt, Selection } from '@antv/x6' import '../index.less' Graph.registerNode( @@ -348,6 +348,16 @@ export const CaseClassExample: React.FC = () => { height: 600, }) + graph.use( + new Selection({ + multiple: true, + rubberband: true, + showNodeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, + }), + ) + const cells: Cell[] = [] const edgeShapes = [ 'extends', diff --git a/examples/src/pages/case/dag.tsx b/examples/src/pages/case/dag.tsx index 0a08d71b130..394f823ea4e 100644 --- a/examples/src/pages/case/dag.tsx +++ b/examples/src/pages/case/dag.tsx @@ -388,6 +388,9 @@ export const CaseDagExample: React.FC = () => { rubberNode: true, modifiers: 'shift', rubberband: true, + showNodeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, }) graph.use(selection) graph.use(new Snapline()) diff --git a/examples/src/pages/case/elk.tsx b/examples/src/pages/case/elk.tsx index 39e3a53a853..79584dcc7f7 100644 --- a/examples/src/pages/case/elk.tsx +++ b/examples/src/pages/case/elk.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef } from 'react' import ELK, { ElkNode, ElkEdge } from 'elkjs/lib/elk-api.js' import elkWorker from 'elkjs/lib/elk-worker.js' -import { Graph, Cell } from '@antv/x6' +import { Graph, Cell, Selection } from '@antv/x6' import elkdata from './elkdata.json' import '../index.less' @@ -83,9 +83,18 @@ export const CaseElkExample: React.FC = () => { container: containerRef.current, width: 1000, height: 600, - interacting: false, }) + graph.use( + new Selection({ + multiple: true, + rubberband: true, + showNodeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, + }), + ) + const portIdToNodeIdMap: Record = {} const cells: Cell[] = [] diff --git a/examples/src/pages/case/er.tsx b/examples/src/pages/case/er.tsx index 502727f7411..a594828e172 100644 --- a/examples/src/pages/case/er.tsx +++ b/examples/src/pages/case/er.tsx @@ -1,4 +1,4 @@ -import { Graph, Shape, Edge } from '@antv/x6' +import { Graph, Shape, Edge, Selection } from '@antv/x6' import React, { useEffect, useRef } from 'react' import './er.less' import './tableNode' @@ -262,6 +262,16 @@ export const ErDiagram: React.FC = ({ tables }) => { const graph = createErGraph(containerRef.current) graphRef.current = graph + graph.use( + new Selection({ + multiple: true, + rubberband: true, + showNodeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, + }), + ) + graph.on('edge:click', ({ edge }) => { toggleType(edge) }) diff --git a/examples/src/pages/case/mind.tsx b/examples/src/pages/case/mind.tsx index f8fe657f398..97e67925feb 100644 --- a/examples/src/pages/case/mind.tsx +++ b/examples/src/pages/case/mind.tsx @@ -183,7 +183,13 @@ export const CaseMindExample: React.FC = () => { connectionPoint: 'anchor', }, }) - const selection = new Selection() + const selection = new Selection({ + multiple: true, + rubberband: true, + showNodeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, + }) graph.use(selection) const keyboard = new Keyboard() graph.use(keyboard) diff --git a/examples/src/pages/case/swimlane.tsx b/examples/src/pages/case/swimlane.tsx index e151b163aff..d283a8c1254 100644 --- a/examples/src/pages/case/swimlane.tsx +++ b/examples/src/pages/case/swimlane.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react' -import { Graph, Cell, CellView, Node } from '@antv/x6' +import { Graph, Cell, CellView, Node, Selection } from '@antv/x6' import '../index.less' Graph.registerNode( @@ -337,6 +337,16 @@ export const CaseSwimlaneExample: React.FC = () => { }, }) + graph.use( + new Selection({ + multiple: true, + rubberband: true, + showNodeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, + }), + ) + const cells: Cell[] = [] data.forEach((item) => { if (item.shape === 'lane-edge') { diff --git a/examples/src/pages/plugins/selection/index.tsx b/examples/src/pages/plugins/selection/index.tsx index 2ff5d8c5b18..4ddd0e4c3a4 100644 --- a/examples/src/pages/plugins/selection/index.tsx +++ b/examples/src/pages/plugins/selection/index.tsx @@ -23,6 +23,8 @@ export const SelectionExample: React.FC = () => { strict: true, showNodeSelectionBox: true, showEdgeSelectionBox: true, + resizable: true, + rotatable: { grid: 15 }, } const selection = new Selection(selectionOptions) graph.use(keyboard) diff --git a/src/model/model.ts b/src/model/model.ts index 8f100f9effb..7c37fff80eb 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -1547,5 +1547,7 @@ export type BatchName = | 'move-segment' | 'move-arrowhead' | 'move-selection' + | 'group-rotate' + | 'group-resize' export interface ToJSONOptions extends CellToJSONOptions {} diff --git a/src/plugin/selection/selection.ts b/src/plugin/selection/selection.ts index 32004e626cc..88b7c038c6e 100644 --- a/src/plugin/selection/selection.ts +++ b/src/plugin/selection/selection.ts @@ -7,11 +7,13 @@ import { type ModifierKey, } from '../../common' import { - type Point, + Point, type PointLike, Rectangle, type RectangleLike, + snapToGrid, } from '../../geometry' +import * as Angle from '../../geometry/angle' import type { Graph } from '../../graph' import type { CollectionAddOptions, @@ -20,6 +22,7 @@ import type { Edge, Model, Node, + ResizeDirection, SetOptions, } from '../../model' import { Cell, Collection, type CollectionEventArgs } from '../../model' @@ -61,6 +64,18 @@ export class SelectionImpl extends View { private static readonly RESTORE_HOLD_TIME = 150 private static readonly MIN_RESTORE_WAIT_TIME = 50 + // Group transform state + protected groupHandlesRendered: boolean = false + protected groupRotating: boolean = false + protected groupResizing: boolean = false + protected groupRotateSnapshots: RotateNodeSnapshot[] = [] + protected groupRotateCenter: PointLike = { x: 0, y: 0 } + protected groupRotatePrevTheta: number = 0 + protected groupRotateTotalAngle: number = 0 + protected groupResizeSnapshots: NodeSnapshot[] = [] + protected groupResizeOrigBBox: Rectangle | null = null + protected groupResizeDirection: ResizeDirection | null = null + public get graph() { return this.options.graph } @@ -113,6 +128,14 @@ export class SelectionImpl extends View { 'onSelectionContainerMouseDown', [`touchstart .${this.prefixClassName(classNames.inner)}`]: 'onSelectionContainerMouseDown', + [`mousedown .${this.prefixClassName(classNames.groupResize)}`]: + 'onGroupResizeMouseDown', + [`touchstart .${this.prefixClassName(classNames.groupResize)}`]: + 'onGroupResizeMouseDown', + [`mousedown .${this.prefixClassName(classNames.groupRotate)}`]: + 'onGroupRotateMouseDown', + [`touchstart .${this.prefixClassName(classNames.groupRotate)}`]: + 'onGroupRotateMouseDown', }, true, ) @@ -434,6 +457,31 @@ export class SelectionImpl extends View { break } + case 'group-rotating': { + this.groupRotating = false + this.graph.model.stopBatch('group-rotate') + this.trigger('selection:rotated', { + cells: this.getSelectedNodes(), + angle: this.groupRotateTotalAngle, + }) + this.groupRotateSnapshots = [] + this.refreshSelectionBoxes() + break + } + + case 'group-resizing': { + this.groupResizing = false + this.groupResizeSnapshots = [] + this.groupResizeOrigBBox = null + this.groupResizeDirection = null + this.graph.model.stopBatch('group-resize') + this.trigger('selection:resized', { + cells: this.getSelectedNodes(), + }) + this.refreshSelectionBoxes() + break + } + default: { this.clean() break @@ -818,6 +866,16 @@ export class SelectionImpl extends View { break } + case 'group-rotating': { + this.doGroupRotating(e) + break + } + + case 'group-resizing': { + this.doGroupResizing(e) + break + } + default: break } @@ -911,6 +969,333 @@ export class SelectionImpl extends View { }) } + // #region Group Transform + + protected getSelectedNodes(): Node[] { + return this.collection + .toArray() + .filter((cell): cell is Node => cell.isNode()) + } + + protected getSelectionBBox(): Rectangle { + const nodes = this.getSelectedNodes() + if (nodes.length === 0) return new Rectangle() + const bbox = this.graph.model.getCellsBBox(nodes) + return bbox || new Rectangle() + } + + protected renderGroupTransformHandles() { + if (this.boxCount < 2) { + this.removeGroupTransformHandles() + return + } + + const resizable = this.options.resizable + const rotatable = this.options.rotatable + + if (!resizable && !rotatable) return + + if (!this.groupHandlesRendered) { + if (resizable) { + const positions: ResizeDirection[] = [ + 'top-left', 'top', 'top-right', 'right', + 'bottom-right', 'bottom', 'bottom-left', 'left', + ] + for (const pos of positions) { + const handle = document.createElement('div') + Dom.addClass( + handle, + this.prefixClassName(classNames.groupResize), + ) + handle.setAttribute('data-position', pos) + this.selectionContainer.appendChild(handle) + } + } + + if (rotatable) { + const handle = document.createElement('div') + Dom.addClass( + handle, + this.prefixClassName(classNames.groupRotate), + ) + this.selectionContainer.appendChild(handle) + } + + this.groupHandlesRendered = true + } + } + + protected removeGroupTransformHandles() { + if (!this.groupHandlesRendered) return + const resizeHandles = this.selectionContainer.querySelectorAll( + `.${this.prefixClassName(classNames.groupResize)}`, + ) + const rotateHandles = this.selectionContainer.querySelectorAll( + `.${this.prefixClassName(classNames.groupRotate)}`, + ) + resizeHandles.forEach((el) => el.remove()) + rotateHandles.forEach((el) => el.remove()) + this.groupHandlesRendered = false + } + + protected onGroupRotateMouseDown(evt: Dom.MouseDownEvent) { + evt.stopPropagation() + evt.preventDefault?.() + + const e = this.normalizeEvent(evt) + const pos = this.graph.clientToLocal(e.clientX, e.clientY) + const bbox = this.getSelectionBBox() + const center = bbox.getCenter() + + const nodes = this.getSelectedNodes() + + // Snapshot each node's original position and angle + const snapshots: RotateNodeSnapshot[] = [] + for (const node of nodes) { + const nodeBBox = node.getBBox() + snapshots.push({ + node, + centerX: nodeBBox.x + nodeBBox.width / 2, + centerY: nodeBBox.y + nodeBBox.height / 2, + width: nodeBBox.width, + height: nodeBBox.height, + angle: node.getAngle(), + }) + } + + this.groupRotating = true + this.groupRotateSnapshots = snapshots + this.groupRotateCenter = center + this.groupRotatePrevTheta = Point.create(pos).theta(center) + this.groupRotateTotalAngle = 0 + + this.graph.model.startBatch('group-rotate') + this.trigger('selection:rotate', { cells: nodes, angle: 0 }) + + this.setEventData(e, { + action: 'group-rotating', + center, + }) + this.delegateDocumentEvents(documentEvents, e.data) + } + + protected doGroupRotating(e: Dom.MouseMoveEvent) { + const pos = this.graph.clientToLocal(e.clientX, e.clientY) + const center = this.groupRotateCenter + + // Accumulate angle using frame-to-frame deltas to avoid 0/360 wrap issues + const currentTheta = Point.create(pos).theta(center) + let frameDelta = this.groupRotatePrevTheta - currentTheta + // Normalize to [-180, 180] to handle wrap-around + if (frameDelta > 180) frameDelta -= 360 + if (frameDelta < -180) frameDelta += 360 + this.groupRotatePrevTheta = currentTheta + + this.groupRotateTotalAngle += frameDelta + + // Apply grid snapping to total angle + let snappedAngle = this.groupRotateTotalAngle + const rotateGrid = this.getRotateGrid() + if (rotateGrid > 0) { + snappedAngle = snapToGrid(snappedAngle, rotateGrid) + } + + // Apply absolute rotation from original snapshots (no cumulative error) + const rad = (snappedAngle * Math.PI) / 180 + const cosA = Math.cos(rad) + const sinA = Math.sin(rad) + const cx = center.x + const cy = center.y + + for (const snapshot of this.groupRotateSnapshots) { + // Rotate original center around group center + const dx = snapshot.centerX - cx + const dy = snapshot.centerY - cy + // Screen-coords clockwise rotation (Y-down) + const newCenterX = cx + dx * cosA - dy * sinA + const newCenterY = cy + dx * sinA + dy * cosA + + // Set position (top-left corner from center) + snapshot.node.setPosition( + newCenterX - snapshot.width / 2, + newCenterY - snapshot.height / 2, + ) + + // Set absolute angle + const newAngle = Angle.normalize(snapshot.angle + snappedAngle) + snapshot.node.rotate(newAngle, { absolute: true }) + } + + this.trigger('selection:rotating', { + cells: this.getSelectedNodes(), + angle: snappedAngle, + }) + + this.refreshSelectionBoxes() + } + + protected getRotateGrid(): number { + const rotatable = this.options.rotatable + if (typeof rotatable === 'object' && rotatable != null) { + return rotatable.grid ?? 15 + } + return 15 + } + + protected onGroupResizeMouseDown(evt: Dom.MouseDownEvent) { + evt.stopPropagation() + evt.preventDefault?.() + + const e = this.normalizeEvent(evt) + const target = e.target as HTMLElement + const position = target.getAttribute('data-position') as ResizeDirection + if (!position) return + + const bbox = this.getSelectionBBox() + const nodes = this.getSelectedNodes() + const snapshots: NodeSnapshot[] = [] + + for (const node of nodes) { + const nodeBBox = node.getBBox() + snapshots.push({ + node, + relX: (nodeBBox.x - bbox.x) / (bbox.width || 1), + relY: (nodeBBox.y - bbox.y) / (bbox.height || 1), + relW: nodeBBox.width / (bbox.width || 1), + relH: nodeBBox.height / (bbox.height || 1), + angle: node.getAngle(), + }) + } + + this.groupResizing = true + this.groupResizeSnapshots = snapshots + this.groupResizeOrigBBox = bbox + this.groupResizeDirection = position + + this.graph.model.startBatch('group-resize') + this.trigger('selection:resize', { cells: nodes }) + + this.setEventData(e, { + action: 'group-resizing', + direction: position, + origBBox: bbox, + startX: e.clientX, + startY: e.clientY, + }) + this.delegateDocumentEvents(documentEvents, e.data) + } + + protected doGroupResizing(e: Dom.MouseMoveEvent) { + const data = this.getEventData(e) + const origBBox = data.origBBox + const direction = data.direction + + const startLocal = this.graph.clientToLocal(data.startX, data.startY) + const currentLocal = this.graph.clientToLocal(e.clientX, e.clientY) + const dx = currentLocal.x - startLocal.x + const dy = currentLocal.y - startLocal.y + + const resizeOpts = this.getResizeOptions() + const minWidth = resizeOpts.minWidth ?? 1 + const minHeight = resizeOpts.minHeight ?? 1 + + let newX = origBBox.x + let newY = origBBox.y + let newW = origBBox.width + let newH = origBBox.height + + switch (direction) { + case 'top-left': + newX += dx; newY += dy; newW -= dx; newH -= dy; break + case 'top': + newY += dy; newH -= dy; break + case 'top-right': + newY += dy; newW += dx; newH -= dy; break + case 'right': + newW += dx; break + case 'bottom-right': + newW += dx; newH += dy; break + case 'bottom': + newH += dy; break + case 'bottom-left': + newX += dx; newW -= dx; newH += dy; break + case 'left': + newX += dx; newW -= dx; break + } + + // Enforce minimum dimensions for the group + const MIN_GROUP_DIMENSION = 10 + const groupMinW = Math.max(minWidth, MIN_GROUP_DIMENSION) + const groupMinH = Math.max(minHeight, MIN_GROUP_DIMENSION) + + if (newW < groupMinW) { + if (direction.includes('left')) { + newX = origBBox.x + origBBox.width - groupMinW + } + newW = groupMinW + } + if (newH < groupMinH) { + if (direction.includes('top')) { + newY = origBBox.y + origBBox.height - groupMinH + } + newH = groupMinH + } + + // Preserve aspect ratio if configured + if (resizeOpts.preserveAspectRatio && origBBox.width > 0 && origBBox.height > 0) { + const ratio = origBBox.width / origBBox.height + if (direction === 'top' || direction === 'bottom') { + newW = newH * ratio + } else if (direction === 'left' || direction === 'right') { + newH = newW / ratio + } else { + // corner: use the larger scale + const scaleX = newW / origBBox.width + const scaleY = newH / origBBox.height + const scale = Math.max(scaleX, scaleY) + newW = origBBox.width * scale + newH = origBBox.height * scale + if (direction.includes('left')) { + newX = origBBox.x + origBBox.width - newW + } + if (direction.includes('top')) { + newY = origBBox.y + origBBox.height - newH + } + } + } + + // Apply proportional transform to each node + for (const snapshot of this.groupResizeSnapshots) { + const nodeW = Math.max(newW * snapshot.relW, minWidth) + const nodeH = Math.max(newH * snapshot.relH, minHeight) + const nodeX = newX + newW * snapshot.relX + const nodeY = newY + newH * snapshot.relY + + snapshot.node.setPosition(nodeX, nodeY) + snapshot.node.resize(nodeW, nodeH) + } + + this.trigger('selection:resizing', { + cells: this.getSelectedNodes(), + }) + + this.refreshSelectionBoxes() + } + + protected getResizeOptions(): { + minWidth?: number + minHeight?: number + preserveAspectRatio?: boolean + } { + const resizable = this.options.resizable + if (typeof resizable === 'object' && resizable != null) { + return resizable + } + return {} + } + + // #endregion + protected getCellViewsInArea(rect: Rectangle) { const graph = this.graph const options = { @@ -986,6 +1371,7 @@ export class SelectionImpl extends View { this.hide() Dom.remove(this.$boxes) this.boxCount = 0 + this.removeGroupTransformHandles() } hide() { @@ -1109,6 +1495,8 @@ export class SelectionImpl extends View { } else if (this.collection.length <= 0 && this.container.parentNode) { this.container.parentNode.removeChild(this.container) } + + this.renderGroupTransformHandles() } protected canShowSelectionBox(cell: Cell) { @@ -1331,6 +1719,20 @@ export interface SelectionImplCommonOptions { // with which mouse button the selection can be started eventTypes?: SelectionEventType[] movingRouterFallback?: string + + // Group transform: resize/rotate all selected nodes as a whole + resizable?: + | boolean + | { + minWidth?: number + minHeight?: number + preserveAspectRatio?: boolean + } + rotatable?: + | boolean + | { + grid?: number + } } export interface SelectionImplOptions extends SelectionImplCommonOptions { @@ -1387,6 +1789,12 @@ export interface SelectionImplEventArgsRecord { selected: Cell[] options: SetOptions } + 'selection:rotate': { cells: Node[]; angle: number } + 'selection:rotating': { cells: Node[]; angle: number } + 'selection:rotated': { cells: Node[]; angle: number } + 'selection:resize': { cells: Node[] } + 'selection:resizing': { cells: Node[] } + 'selection:resized': { cells: Node[] } } export interface SelectionImplEventArgs @@ -1404,6 +1812,8 @@ export const classNames = { content: `${baseClassName}-content`, rubberband: `${baseClassName}-rubberband`, selected: `${baseClassName}-selected`, + groupResize: `${baseClassName}-group-resize`, + groupRotate: `${baseClassName}-group-rotate`, } export const documentEvents = { @@ -1419,7 +1829,7 @@ export function depthComparator(cell: Cell) { } export interface CommonEventData { - action: 'selecting' | 'translating' + action: 'selecting' | 'translating' | 'group-rotating' | 'group-resizing' } export interface SelectingEventData extends CommonEventData { @@ -1459,3 +1869,38 @@ export interface ResizingEventData { minWidth: number minHeight: number } + +export interface GroupTransformEventData extends CommonEventData { + center: PointLike +} + +export interface GroupRotatingEventData extends CommonEventData { + action: 'group-rotating' + center: PointLike +} + +export interface GroupResizingEventData extends CommonEventData { + action: 'group-resizing' + direction: ResizeDirection + origBBox: Rectangle + startX: number + startY: number +} + +export interface NodeSnapshot { + node: Node + relX: number + relY: number + relW: number + relH: number + angle: number +} + +export interface RotateNodeSnapshot { + node: Node + centerX: number + centerY: number + width: number + height: number + angle: number +} diff --git a/src/plugin/selection/style/raw.ts b/src/plugin/selection/style/raw.ts index 30f8afb9cc7..f6cf5d099a2 100644 --- a/src/plugin/selection/style/raw.ts +++ b/src/plugin/selection/style/raw.ts @@ -69,4 +69,71 @@ export const content = `.x6-widget-selection { font-size: 10px; background-color: #6a6b8a; } +.x6-widget-selection-group-resize { + position: absolute; + width: 10px; + height: 10px; + border-radius: 2px; + background: #fff; + border: 2px solid #feb663; + box-sizing: border-box; + z-index: 10; +} +.x6-widget-selection-group-resize[data-position='top-left'] { + top: -5px; + left: -5px; + cursor: nwse-resize; +} +.x6-widget-selection-group-resize[data-position='top'] { + top: -5px; + left: 50%; + margin-left: -5px; + cursor: ns-resize; +} +.x6-widget-selection-group-resize[data-position='top-right'] { + top: -5px; + right: -5px; + cursor: nesw-resize; +} +.x6-widget-selection-group-resize[data-position='right'] { + top: 50%; + right: -5px; + margin-top: -5px; + cursor: ew-resize; +} +.x6-widget-selection-group-resize[data-position='bottom-right'] { + bottom: -5px; + right: -5px; + cursor: nwse-resize; +} +.x6-widget-selection-group-resize[data-position='bottom'] { + bottom: -5px; + left: 50%; + margin-left: -5px; + cursor: ns-resize; +} +.x6-widget-selection-group-resize[data-position='bottom-left'] { + bottom: -5px; + left: -5px; + cursor: nesw-resize; +} +.x6-widget-selection-group-resize[data-position='left'] { + top: 50%; + left: -5px; + margin-top: -5px; + cursor: ew-resize; +} +.x6-widget-selection-group-rotate { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + border: 2px solid #31d0c6; + box-sizing: border-box; + top: -22px; + left: -22px; + cursor: crosshair; + z-index: 10; +} `