diff --git a/packages/g6/package.json b/packages/g6/package.json index e23ec69e5c..1cfb82f931 100644 --- a/packages/g6/package.json +++ b/packages/g6/package.json @@ -83,7 +83,6 @@ "insert-css": "^2.0.0", "stats-js": "^1.0.1", "tslib": "^2.5.0", - "uuid": "^9.0.0", "typedoc-plugin-markdown": "^3.16.0", "typescript": "^5.1.6" }, diff --git a/packages/g6/src/runtime/graph.ts b/packages/g6/src/runtime/graph.ts index 087eb66d88..e0db2a0071 100644 --- a/packages/g6/src/runtime/graph.ts +++ b/packages/g6/src/runtime/graph.ts @@ -1,5 +1,5 @@ import EventEmitter from '@antv/event-emitter'; -import { AABB, Canvas, DisplayObject, PointLike } from '@antv/g'; +import { AABB, Canvas, Cursor, DisplayObject, PointLike } from '@antv/g'; import { GraphChange, ID } from '@antv/graphlib'; import { clone, @@ -1763,6 +1763,15 @@ export default class Graph return this.interactionController.getMode(); } + /** + * Set the cursor. But the cursor in item's style has higher priority. + * @param cursor + */ + public setCursor(cursor: Cursor) { + this.canvas.setCursor(cursor); + this.transientCanvas.setCursor(cursor); + } + /** * Add behavior(s) to mode(s). * @param behaviors behavior names or configs diff --git a/packages/g6/src/stdlib/behavior/create-edge.ts b/packages/g6/src/stdlib/behavior/create-edge.ts index 0f09ab0454..91542da608 100644 --- a/packages/g6/src/stdlib/behavior/create-edge.ts +++ b/packages/g6/src/stdlib/behavior/create-edge.ts @@ -1,15 +1,14 @@ -import type { ID, IG6GraphEvent, EdgeModel } from '../../types'; +import type { IG6GraphEvent } from '../../types'; import { warn } from '../../util/warn'; import { generateEdgeID } from '../../util/item'; import { Behavior } from '../../types/behavior'; +import { EdgeDisplayModelData } from '../../types/edge'; const KEYBOARD_TRIGGERS = ['shift', 'ctrl', 'control', 'alt', 'meta'] as const; const EVENT_TRIGGERS = ['click', 'drag'] as const; -enum Events { - BEFORE_ADDING_EDGE = 'beforeaddingedge', - AFTER_ADDING_EDGE = 'afteraddingedge', -} +const VIRTUAL_EDGE_ID = 'g6-create-edge-virtual-edge'; +const DUMMY_NODE_ID = 'g6-create-edge-dummy-node'; // type Trigger = (typeof EVENT_TRIGGERS)[number]; @@ -29,7 +28,19 @@ interface CreateEdgeOptions { /** * Config of the created edge. */ - edgeConfig: any; + edgeConfig: EdgeDisplayModelData; + /** + * The event name to trigger after creating the virtual edge. + */ + createVirtualEventName?: string; + /** + * The event name to trigger after creating the actual edge. + */ + createActualEventName?: string; + /** + * The event name to trigger after canceling the behavior. + */ + cancelCreateEventName?: string; /** * Whether allow the behavior happen on the current item. */ @@ -49,7 +60,7 @@ const DEFAULT_OPTIONS: CreateEdgeOptions = { edgeConfig: {}, }; -export default class CreateEdge extends Behavior { +export class CreateEdge extends Behavior { isKeyDown = false; addingEdge = null; dummyNode = null; @@ -90,7 +101,7 @@ export default class CreateEdge extends Behavior { trigger === CLICK_NAME ? { 'node:click': this.handleCreateEdge, - mousemove: this.updateEndPoint, + pointermove: this.updateEndPoint, 'edge:click': this.cancelCreating, 'canvas:click': this.cancelCreating, 'combo:click': this.handleCreateEdge, @@ -99,9 +110,7 @@ export default class CreateEdge extends Behavior { 'node:dragstart': this.handleCreateEdge, 'combo:dragstart': this.handleCreateEdge, drag: this.updateEndPoint, - 'node:drop': this.handleCreateEdge, - 'combo:drop': this.handleCreateEdge, - dragend: this.onDragEnd, + drop: this.onDrop, }; const keyboardEvents = key @@ -118,6 +127,10 @@ export default class CreateEdge extends Behavior { }; handleCreateEdge = (e: IG6GraphEvent) => { + if (this.options.key && !this.isKeyDown) { + return; + } + if (this.options.shouldEnd(e)) { return; } @@ -125,88 +138,98 @@ export default class CreateEdge extends Behavior { const { graph, options, addingEdge } = this; const currentNodeId = e.itemId; - const edgeConfig = options.edgeConfig; - const isDragTrigger = options.trigger === 'drag'; + const { edgeConfig, createVirtualEventName, createActualEventName } = + options; if (addingEdge) { - const updateConfig = { - id: addingEdge.id, + // create edge end, add the actual edge to graph and remove the virtual edge and node + graph.addData('edge', { + id: generateEdgeID(addingEdge.source, currentNodeId), source: addingEdge.source, target: currentNodeId, data: { - type: currentNodeId === addingEdge.source ? 'loop' : edgeConfig.type, ...edgeConfig, + type: + currentNodeId === addingEdge.source ? 'loop-edge' : edgeConfig.type, }, - }; - - graph.emit(Events.BEFORE_ADDING_EDGE); - graph.updateData('edge', updateConfig); - graph.emit(Events.AFTER_ADDING_EDGE, { edge: addingEdge }); - if (!isDragTrigger) { - // this.cancelCreating(); - this.addingEdge = null; + }); + if (createActualEventName) { + graph.emit(createActualEventName, { edge: addingEdge }); } + this.cancelCreating(); return; } - if (isDragTrigger) { - this.dummyNode = graph.addData('node', { - id: 'dummy', - data: { - // type: 'circle-node', - r: 1, - label: '', + this.dummyNode = graph.addData('node', { + id: DUMMY_NODE_ID, + data: { + x: e.canvas.x, + y: e.canvas.y, + keyShape: { + opacity: 0, + interactive: false, }, - }); - } - + labelShape: { + opacity: 0, + }, + anchorPoints: [[0.5, 0.5]], + }, + }); this.addingEdge = graph.addData('edge', { - id: generateEdgeID(currentNodeId, currentNodeId), + id: VIRTUAL_EDGE_ID, source: currentNodeId, - target: isDragTrigger ? 'dummy' : currentNodeId, + target: DUMMY_NODE_ID, data: { ...edgeConfig, }, - } as EdgeModel); + }); + if (createVirtualEventName) { + graph.emit(createVirtualEventName, { edge: this.addingEdge }); + } }; - onDragEnd = (e: IG6GraphEvent) => { + onDrop = async (e: IG6GraphEvent) => { const { addingEdge, options, graph } = this; - const { edgeConfig, key } = options; + const { edgeConfig, key, createActualEventName } = options; if (key && !this.isKeyDown) { return; } - if (addingEdge) { + if (!addingEdge) { return; } - const { itemId, itemType } = e; + const elements = await this.graph.canvas.document.elementsFromPoint( + e.canvas.x, + e.canvas.y, + ); + const currentIds = elements + // @ts-ignore TODO: G type + .map((ele) => ele.parentNode.getAttribute?.('data-item-id')) + .filter((id) => id !== undefined && !DUMMY_NODE_ID !== id); + const dropId = currentIds.find( + (id) => this.graph.getComboData(id) || this.graph.getNodeData(id), + ); - if ( - !itemId || - itemId === addingEdge.source || - itemType !== 'node' || - itemId === 'dummy' - ) { + if (!dropId) { this.cancelCreating(); + return; } - const updateConfig = { - id: addingEdge.id, + graph.addData('edge', { + id: generateEdgeID(addingEdge.source, dropId), source: addingEdge.source, - target: itemId, + target: dropId, data: { - type: itemId === addingEdge.source ? 'loop' : edgeConfig.type, ...edgeConfig, + type: dropId === addingEdge.source ? 'loop-edge' : edgeConfig.type, }, - }; - - graph.emit(Events.BEFORE_ADDING_EDGE); - graph.updateData('edge', updateConfig); - graph.emit(Events.AFTER_ADDING_EDGE, { edge: addingEdge }); + }); + if (createActualEventName) { + graph.emit(createActualEventName, { edge: addingEdge }); + } this.cancelCreating(); }; @@ -231,15 +254,24 @@ export default class CreateEdge extends Behavior { graph.updatePosition('node', { id: targetId, data: { - x: e.offset.x, - y: e.offset.y, + x: e.canvas.x, + y: e.canvas.y, }, }); }; cancelCreating = () => { - this.removeAddingEdge(); - this.removeDummyNode(); + if (this.addingEdge) { + this.graph.removeData('edge', VIRTUAL_EDGE_ID); + this.addingEdge = null; + } + if (this.dummyNode) { + this.graph.removeData('node', DUMMY_NODE_ID); + this.dummyNode = null; + } + if (this.options.cancelCreateEventName) { + this.graph.emit(this.options.cancelCreateEventName, {}); + } }; onKeyDown = (e: KeyboardEvent) => { @@ -260,18 +292,4 @@ export default class CreateEdge extends Behavior { } this.isKeyDown = false; }; - - removeAddingEdge() { - if (this.addingEdge) { - this.graph.removeData('edge', this.addingEdge.id); - this.addingEdge = null; - } - } - - removeDummyNode() { - if (this.dummyNode) { - this.graph.removeData('node', this.dummyNode.id); - this.dummyNode = null; - } - } } diff --git a/packages/g6/src/stdlib/index.ts b/packages/g6/src/stdlib/index.ts index 0802a2a94c..f8f109d98e 100644 --- a/packages/g6/src/stdlib/index.ts +++ b/packages/g6/src/stdlib/index.ts @@ -110,6 +110,7 @@ const stdLib = { }, edges: { 'line-edge': LineEdge, + 'loop-edge': LoopEdge, }, combos: { 'circle-combo': CircleCombo, @@ -248,6 +249,7 @@ const Extensions = { CollapseExpandCombo, DragNode, DragCombo, + CreateEdge, //plugins BasePlugin, History, diff --git a/packages/g6/src/types/graph.ts b/packages/g6/src/types/graph.ts index b3b51636ba..2307738ba1 100644 --- a/packages/g6/src/types/graph.ts +++ b/packages/g6/src/types/graph.ts @@ -1,5 +1,5 @@ import EventEmitter from '@antv/event-emitter'; -import { AABB, Canvas, DisplayObject, PointLike } from '@antv/g'; +import { AABB, Canvas, Cursor, DisplayObject, PointLike } from '@antv/g'; import { ID } from '@antv/graphlib'; import { Command } from '../stdlib/plugin/history/command'; import { Hooks } from '../types/hook'; @@ -591,6 +591,11 @@ export interface IGraph< * @group Interaction */ getMode: () => string; + /** + * Set the cursor. But the cursor in item's style has higher priority. + * @param cursor + */ + setCursor: (cursor: Cursor) => void; /** * Add behavior(s) to mode(s). * @param behaviors behavior names or configs diff --git a/packages/g6/src/util/item.ts b/packages/g6/src/util/item.ts index c9fbe51cdb..e3f86611b4 100644 --- a/packages/g6/src/util/item.ts +++ b/packages/g6/src/util/item.ts @@ -1,12 +1,12 @@ import { ID } from '@antv/graphlib'; import { Group } from '@antv/g'; +import { uniqueId } from '@antv/util'; import { IGraph } from '../types'; import Combo from '../item/combo'; import Edge from '../item/edge'; import Node from '../item/node'; import { GraphCore } from '../types/data'; import { getCombinedBoundsByItem } from './shape'; -import { v4 as uuidv4 } from 'uuid'; /** * Find the edges whose source and target are both in the ids. @@ -144,5 +144,5 @@ export const upsertTransientItem = ( * @returns */ export function generateEdgeID(source: ID, target: ID) { - return [source, target, uuidv4()].join('->'); + return [source, target, uniqueId()].join('->'); } diff --git a/packages/g6/tests/demo/behaviors/create-edge.ts b/packages/g6/tests/demo/behaviors/create-edge.ts index 593b03be84..64f10253fc 100644 --- a/packages/g6/tests/demo/behaviors/create-edge.ts +++ b/packages/g6/tests/demo/behaviors/create-edge.ts @@ -1,8 +1,20 @@ -import G6 from '../../../src/index'; +import { extend, Graph, Extensions } from '../../../src/index'; import { TestCaseContext } from '../interface'; -export default (context: TestCaseContext) => { - return new G6.Graph({ +export default (context: TestCaseContext, options) => { + const createEdgeOptions = options || { + trigger: 'click', + edgeConfig: { keyShape: { stroke: '#f00' } }, + createVirtualEventName: 'begincreate', + cancelCreateEventName: 'cancelcreate', + }; + const ExtGraph = extend(Graph, { + behaviors: { + 'create-edge': Extensions.CreateEdge, + 'brush-select': Extensions.BrushSelect, + }, + }); + const graph = new ExtGraph({ ...context, layout: { type: 'grid', @@ -33,7 +45,24 @@ export default (context: TestCaseContext) => { ], }, modes: { - default: [{ type: 'create-edge', trigger: 'click' }], + default: [ + { + type: 'brush-select', + eventName: 'afterbrush', + }, + { + type: 'create-edge', + ...createEdgeOptions, + }, + ], }, }); + + graph.on('begincreate', (e) => { + graph.setCursor('crosshair'); + }); + graph.on('cancelcreate', (e) => { + graph.setCursor('default'); + }); + return graph; }; diff --git a/packages/g6/tests/integration/behaviors-create-edge.spec.ts b/packages/g6/tests/integration/behaviors-create-edge.spec.ts new file mode 100644 index 0000000000..bb1ebeab52 --- /dev/null +++ b/packages/g6/tests/integration/behaviors-create-edge.spec.ts @@ -0,0 +1,192 @@ +import { resetEntityCounter } from '@antv/g'; +import { createContext } from './utils'; +import createEdge from '../demo/behaviors/create-edge'; +import './utils/useSnapshotMatchers'; + +describe('Create edge behavior', () => { + beforeEach(() => { + /** + * SVG Snapshot testing will generate a unique id for each element. + * Reset to 0 to keep snapshot consistent. + */ + resetEntityCounter(); + }); + + it('trigger click should be rendered correctly with Canvas2D', (done) => { + const dir = `${__dirname}/snapshots/canvas/behaviors`; + const { backgroundCanvas, canvas, transientCanvas, container } = + createContext('canvas', 500, 500); + + const graph = createEdge( + { + container, + backgroundCanvas, + canvas, + transientCanvas, + width: 500, + height: 500, + }, + { + trigger: 'click', + edgeConfig: { keyShape: { stroke: '#f00' } }, + createVirtualEventName: 'begincreate', + cancelCreateEventName: 'cancelcreate', + }, + ); + + graph.on('afterlayout', async () => { + graph.emit('node:click', { + itemId: 'node5', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + graph.emit('pointermove', { canvas: { x: 100, y: 100 } }); + await expect(canvas).toMatchCanvasSnapshot( + dir, + 'behaviors-create-edge-click-begin', + ); + + graph.emit('node:click', { + itemId: 'node2', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + await expect(canvas).toMatchCanvasSnapshot( + dir, + 'behaviors-create-edge-click-finish', + ); + + graph.emit('node:click', { + itemId: 'node5', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + graph.emit('node:click', { + itemId: 'node5', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + await expect(canvas).toMatchCanvasSnapshot( + dir, + 'behaviors-create-edge-click-loop', + ); + + graph.destroy(); + done(); + }); + }); + + it('trigger drag should be rendered correctly with Canvas2D', (done) => { + const dir = `${__dirname}/snapshots/canvas/behaviors`; + const { backgroundCanvas, canvas, transientCanvas, container } = + createContext('canvas', 500, 500); + + const graph = createEdge( + { + container, + backgroundCanvas, + canvas, + transientCanvas, + width: 500, + height: 500, + }, + { + trigger: 'drag', + edgeConfig: { keyShape: { stroke: '#f00' } }, + }, + ); + + graph.on('afterlayout', async () => { + graph.emit('node:dragstart', { + itemId: 'node5', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + graph.emit('drag', { canvas: { x: 100, y: 100 } }); + await expect(canvas).toMatchCanvasSnapshot( + dir, + 'behaviors-create-edge-drag-begin', + ); + + const nodeModel = graph.getNodeData('node2'); + graph.emit('drop', { + itemId: 'node2', + itemType: 'node', + canvas: { x: nodeModel?.data.x, y: nodeModel?.data.y }, + }); + await expect(canvas).toMatchCanvasSnapshot( + dir, + 'behaviors-create-edge-drag-finish', + ); + + graph.emit('node:dragstart', { + itemId: 'node5', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + graph.emit('drag', { canvas: { x: 100, y: 100 } }); + const node5Model = graph.getNodeData('node5'); + graph.emit('drop', { + itemId: 'node5', + itemType: 'node', + canvas: { x: node5Model?.data.x, y: node5Model?.data.y }, + }); + await expect(canvas).toMatchCanvasSnapshot( + dir, + 'behaviors-create-edge-drag-loop', + ); + + graph.destroy(); + done(); + }); + }); + + it('should be rendered correctly with SVG', (done) => { + const dir = `${__dirname}/snapshots/svg/behaviors`; + const { backgroundCanvas, canvas, transientCanvas, container } = + createContext('svg', 500, 500); + + const graph = createEdge( + { + container, + backgroundCanvas, + canvas, + transientCanvas, + width: 500, + height: 500, + }, + { + trigger: 'click', + edgeConfig: { keyShape: { stroke: '#f00' } }, + createVirtualEventName: 'begincreate', + cancelCreateEventName: 'cancelcreate', + }, + ); + + graph.on('afterlayout', async () => { + graph.emit('node:click', { + itemId: 'node5', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + graph.emit('pointermove', { canvas: { x: 100, y: 100 } }); + await expect(canvas).toMatchSVGSnapshot( + dir, + 'behaviors-create-edge-click-begin', + ); + + graph.emit('node:click', { + itemId: 'node2', + itemType: 'node', + canvas: { x: 100, y: 100 }, + }); + await expect(canvas).toMatchSVGSnapshot( + dir, + 'behaviors-create-edge-click-finish', + ); + + graph.destroy(); + done(); + }); + }); +}); diff --git a/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-begin.png b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-begin.png new file mode 100644 index 0000000000..38da62afa5 Binary files /dev/null and b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-begin.png differ diff --git a/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-finish.png b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-finish.png new file mode 100644 index 0000000000..d253e5d31c Binary files /dev/null and b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-finish.png differ diff --git a/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-loop.png b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-loop.png new file mode 100644 index 0000000000..acc834ded8 Binary files /dev/null and b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-click-loop.png differ diff --git a/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-begin.png b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-begin.png new file mode 100644 index 0000000000..38da62afa5 Binary files /dev/null and b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-begin.png differ diff --git a/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-finish.png b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-finish.png new file mode 100644 index 0000000000..d253e5d31c Binary files /dev/null and b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-finish.png differ diff --git a/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-loop.png b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-loop.png new file mode 100644 index 0000000000..acc834ded8 Binary files /dev/null and b/packages/g6/tests/integration/snapshots/canvas/behaviors/behaviors-create-edge-drag-loop.png differ diff --git a/packages/g6/tests/integration/snapshots/svg/behaviors/behaviors-create-edge-click-begin.svg b/packages/g6/tests/integration/snapshots/svg/behaviors/behaviors-create-edge-click-begin.svg new file mode 100644 index 0000000000..0d1479ed6b --- /dev/null +++ b/packages/g6/tests/integration/snapshots/svg/behaviors/behaviors-create-edge-click-begin.svg @@ -0,0 +1 @@ +node1node2node3node4node5g6-create... \ No newline at end of file diff --git a/packages/g6/tests/integration/snapshots/svg/behaviors/behaviors-create-edge-click-finish.svg b/packages/g6/tests/integration/snapshots/svg/behaviors/behaviors-create-edge-click-finish.svg new file mode 100644 index 0000000000..21e2d9b917 --- /dev/null +++ b/packages/g6/tests/integration/snapshots/svg/behaviors/behaviors-create-edge-click-finish.svg @@ -0,0 +1 @@ +node1node2node3node4node5 \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2da580379..93a0f07322 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: typescript: specifier: ^5.1.6 version: 5.1.6 + uuid: + specifier: ^9.0.0 + version: 9.0.0 devDependencies: '@rollup/plugin-terser': specifier: ^0.4.3 @@ -40178,6 +40181,11 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + /uuid@9.0.0: + resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} + hasBin: true + dev: false + /uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'}