chore: refine create-edge and tests

This commit is contained in:
Yanyan-Wang 2023-09-20 16:46:53 +08:00
parent a28af5ae62
commit e9298b2721
17 changed files with 347 additions and 83 deletions

View File

@ -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"
},

View File

@ -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<B extends BehaviorRegistry, T extends ThemeRegistry>
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

View File

@ -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;
}
}
}

View File

@ -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,

View File

@ -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

View File

@ -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('->');
}

View File

@ -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;
};

View File

@ -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();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -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'}