mirror of
https://gitee.com/antv/g6.git
synced 2024-11-29 18:28:19 +08:00
feat: add behavior controller and click-select (#4328)
* feat: add behavior controller and click-select * fix: fix code review --------- Co-authored-by: 14 <yisi.wys@antgroup.com>
This commit is contained in:
parent
0f23a181db
commit
2d9cdb8f41
@ -44,6 +44,7 @@
|
||||
"prettier": "prettier -c --write \"**/*\"",
|
||||
"test": "jest",
|
||||
"test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/item-spec.ts",
|
||||
"test-behavior": "DEBUG_MODE=1 jest --watch ./tests/unit/click-select-spec.ts",
|
||||
"lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx",
|
||||
"watch": "father build -w"
|
||||
},
|
||||
@ -69,7 +70,7 @@
|
||||
"typedoc-plugin-markdown": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.1",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/node": "13.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.11.1",
|
||||
"@umijs/fabric": "^2.0.0",
|
||||
@ -92,4 +93,4 @@
|
||||
"typedoc": "^0.23.24",
|
||||
"typescript": "^4.6.3"
|
||||
}
|
||||
}
|
||||
}
|
@ -60,6 +60,8 @@ export default abstract class Item implements IItem {
|
||||
public init(props) {
|
||||
const { model, containerGroup, mapper, stateMapper, renderExtensions} = props;
|
||||
this.group = new Group();
|
||||
this.group.setAttribute('data-item-type', this.type);
|
||||
this.group.setAttribute('data-item-id', props.model.id);
|
||||
containerGroup.appendChild(this.group);
|
||||
this.model = model;
|
||||
this.mapper = mapper;
|
||||
@ -331,7 +333,7 @@ export default abstract class Item implements IItem {
|
||||
/**
|
||||
* Re-draw the item with merged state styles.
|
||||
* @param previousStates previous states
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
private drawWithStates(previousStates: State[]) {
|
||||
if (!this.stateMapper) return;
|
||||
@ -395,7 +397,7 @@ export default abstract class Item implements IItem {
|
||||
// diffData
|
||||
undefined,
|
||||
// diffState
|
||||
{
|
||||
{
|
||||
previous: previousStates,
|
||||
current: this.states,
|
||||
}
|
||||
|
@ -22,11 +22,13 @@ export default class Node extends Item {
|
||||
this.draw(this.displayModel as NodeDisplayModel);
|
||||
}
|
||||
public draw(displayModel: NodeDisplayModel, diffData?: { previous: NodeModelData; current: NodeModelData }, diffState?: { previous: State[], current: State[] }) {
|
||||
const { group, renderExt, shapeMap: prevShapeMap } = this;
|
||||
const { group, renderExt, shapeMap: prevShapeMap, model } = this;
|
||||
const { data } = displayModel;
|
||||
const { x = 0, y = 0 } = data;
|
||||
group.style.x = x;
|
||||
group.style.y = y;
|
||||
this.group.setAttribute('data-item-type', 'node');
|
||||
this.group.setAttribute('data-item-id', model.id);
|
||||
const shapeMap = renderExt.draw(displayModel, this.shapeMap, diffData, diffState);
|
||||
|
||||
// add shapes to group, and update shapeMap
|
||||
|
@ -1,95 +1,316 @@
|
||||
import { isObject } from '@antv/util';
|
||||
import { registry } from '../../stdlib';
|
||||
import { IGraph } from '../../types';
|
||||
import { getExtension } from '../../util/extension';
|
||||
import { IElement } from '@antv/g';
|
||||
import { IGraph } from "../../types";
|
||||
import { registery } from '../../stdlib';
|
||||
import { getExtension } from "../../util/extension";
|
||||
import { Behavior } from "../../types/behavior";
|
||||
import { CANVAS_EVENT_TYPE, DOM_EVENT_TYPE, IG6GraphEvent } from '../../types/event';
|
||||
import { getItemInfoFromElement, ItemInfo } from '../../util/event';
|
||||
|
||||
type Listener = (event: IG6GraphEvent) => void;
|
||||
|
||||
/**
|
||||
* Wraps the listener with error logging.
|
||||
* @returns a new listener with error logging.
|
||||
*/
|
||||
const wrapListener = (type: string, eventName: string, listener: Listener): Listener => {
|
||||
return (event: any) => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error(`G6: Error occurred in "${eventName}" phase of the behavior "${type}"!`)
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the interaction extensions and graph modes;
|
||||
* Storage related data.
|
||||
*/
|
||||
export class InteractionController {
|
||||
public extensions = {};
|
||||
public graph: IGraph;
|
||||
public mode: string;
|
||||
private graph: IGraph;
|
||||
private mode: string;
|
||||
|
||||
/**
|
||||
* Available behaviors of current mode.
|
||||
* @example
|
||||
* { 'drag-node': DragNode, 'drag-canvas': DragCanvas }
|
||||
*/
|
||||
private behaviorMap: Map<string, Behavior> = new Map();
|
||||
|
||||
/**
|
||||
* Listeners added by all current behaviors.
|
||||
* @example
|
||||
* {
|
||||
* 'drag-node': { 'node:pointerEnter': function },
|
||||
* }
|
||||
*/
|
||||
private listenersMap: Record<string, Record<string, Listener>> = {};
|
||||
|
||||
private prevItemInfo: ItemInfo;
|
||||
|
||||
constructor(graph: IGraph<any>) {
|
||||
this.graph = graph;
|
||||
this.initEvents();
|
||||
this.tap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe the lifecycle of graph.
|
||||
*/
|
||||
private tap() {
|
||||
this.extensions = this.getExtensions();
|
||||
this.graph.hooks.init.tap(() => this.onModeChange(this, { mode: 'default' }));
|
||||
this.graph.hooks.modechange.tap((...params) => this.onModeChange(this, ...params));
|
||||
this.graph.hooks.behaviorchange.tap((...params) => this.onBehaviorChange(this, ...params));
|
||||
private tap = (): void => {
|
||||
this.graph.hooks.init.tap(() => this.onModeChange({ mode: 'default' }));
|
||||
this.onModeChange({ mode: 'default' });
|
||||
this.graph.hooks.modechange.tap(this.onModeChange);
|
||||
this.graph.hooks.behaviorchange.tap(this.onBehaviorChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extensions from useLib, stdLib is a sub set of useLib.
|
||||
* @returns
|
||||
*/
|
||||
private getExtensions() {
|
||||
const { modes = {} } = this.graph.getSpecification();
|
||||
const modeBehaviors = {};
|
||||
Object.keys(modes).forEach((mode) => {
|
||||
modeBehaviors[mode] = modes[mode]
|
||||
.map((config) => getExtension(config, registry.useLib, 'behavior'))
|
||||
.filter((behavior) => !!behavior);
|
||||
private validateMode = (mode: string): boolean => {
|
||||
if (mode === 'default') return true;
|
||||
const modes = this.graph.getSpecification().modes || {};
|
||||
return Object.keys(modes).includes(mode);
|
||||
}
|
||||
|
||||
private initBehavior = (config: string | { type: string }): Behavior | null => {
|
||||
const type = typeof config === 'string' ? config : (config as any).type;
|
||||
if (this.behaviorMap.has(type)) {
|
||||
console.error(`G6: Failed to add behavior "${type}"! It was already added.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Get behavior extensions from useLib.
|
||||
const BehaviorClass = getExtension(config, registery.useLib, 'behavior');
|
||||
const options = typeof config === 'string' ? {} : config;
|
||||
const behavior = new BehaviorClass(options);
|
||||
behavior.graph = this.graph;
|
||||
if (behavior) {
|
||||
this.behaviorMap.set(type, behavior);
|
||||
}
|
||||
return behavior;
|
||||
} catch (error) {
|
||||
console.error(`G6: Failed to initialize behavior "${type}"!`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private destroyBehavior = (type: string, behavior: Behavior) => {
|
||||
try {
|
||||
behavior.destroy();
|
||||
} catch (error) {
|
||||
console.error(`G6: Failed to destroy behavior "${type}"!`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private addListeners = (type: string, behavior: Behavior) => {
|
||||
const events = behavior.getEvents();
|
||||
this.listenersMap[type] = {};
|
||||
Object.keys(events).forEach(eventName => {
|
||||
// Wrap the listener with error logging.
|
||||
const listener = wrapListener(type, eventName, events[eventName]);
|
||||
this.graph.on(eventName, listener);
|
||||
this.listenersMap[type][eventName] = listener;
|
||||
});
|
||||
}
|
||||
|
||||
private removeListeners = (type: string) => {
|
||||
Object.keys(this.listenersMap[type] || {}).forEach(eventName => {
|
||||
const listener = this.listenersMap[type][eventName];
|
||||
this.graph.off(eventName, listener);
|
||||
});
|
||||
return modeBehaviors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener of graph's init hook. Add listeners from behaviors to graph.
|
||||
* @param param contains the mode to switch to
|
||||
*/
|
||||
private onModeChange(self, param: { mode: string }) {
|
||||
self.mode = param.mode;
|
||||
// TODO: add listeners from behaviors in mode
|
||||
// ...
|
||||
private onModeChange = (param: { mode: string }) => {
|
||||
const { mode } = param;
|
||||
|
||||
// Skip if set to same mode.
|
||||
if (this.mode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateMode(mode)) {
|
||||
console.warn(`G6: Mode "${mode}" was not specified in current graph.`);
|
||||
}
|
||||
|
||||
this.mode = mode;
|
||||
|
||||
// 1. Remove listeners && destroy current behaviors.
|
||||
this.behaviorMap.forEach((behavior, type) => {
|
||||
this.removeListeners(type);
|
||||
this.destroyBehavior(type, behavior);
|
||||
});
|
||||
|
||||
// 2. Initialize new behaviors.
|
||||
this.behaviorMap.clear();
|
||||
const behaviorConfigs = this.graph.getSpecification().modes?.[mode] || [];
|
||||
behaviorConfigs.forEach(config => {
|
||||
this.initBehavior(config);
|
||||
});
|
||||
|
||||
// 3. Add listeners for each behavior.
|
||||
this.listenersMap = {};
|
||||
this.behaviorMap.forEach((behavior, type) => {
|
||||
this.addListeners(type, behavior);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener of graph's behaviorchange hook. Update, add, or remove behaviors from modes.
|
||||
* @param param contains action, modes, and behaviors
|
||||
*/
|
||||
private onBehaviorChange(
|
||||
self,
|
||||
param: {
|
||||
action: 'update' | 'add' | 'remove';
|
||||
modes: string[];
|
||||
behaviors: (string | { key: string; type: string })[];
|
||||
},
|
||||
) {
|
||||
private onBehaviorChange = (param: {
|
||||
action: 'update' | 'add' | 'remove',
|
||||
modes: string[],
|
||||
behaviors: (string | { type: string })[],
|
||||
}) => {
|
||||
const { action, modes, behaviors } = param;
|
||||
modes.forEach((mode) => {
|
||||
switch (action) {
|
||||
case 'add':
|
||||
behaviors.forEach((config) =>
|
||||
self.extensions[mode].push(getExtension(config, registry.useLib, 'behavior')),
|
||||
);
|
||||
break;
|
||||
case 'remove':
|
||||
behaviors.forEach((key) => {
|
||||
self.extensions[mode] = self.extensions[mode].filter(
|
||||
(behavior) => behavior.getKey() === key,
|
||||
);
|
||||
});
|
||||
break;
|
||||
case 'update':
|
||||
behaviors.forEach((config) => {
|
||||
if (isObject(config) && config.hasOwnProperty('key')) {
|
||||
const behaviorItem = self.extensions[mode].find(behavior => behavior.getKey() === config.key);
|
||||
if (behaviorItem) behaviorItem.updateConfig(config);
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
modes.forEach(mode => {
|
||||
// Do nothing if it's not the current mode.
|
||||
// Changes are recorded in `graph.specification`. They are applied in onModeChange.
|
||||
if (mode !== this.mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
behaviors.forEach(config => {
|
||||
const type = typeof config === 'string' ? config : (config as any).type;
|
||||
const behavior = this.initBehavior(config);
|
||||
if (behavior) {
|
||||
this.addListeners(type, behavior);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'remove') {
|
||||
behaviors.forEach(config => {
|
||||
const type = typeof config === 'string' ? config : (config as any).type;
|
||||
const behavior = this.behaviorMap.get(type);
|
||||
if (behavior) {
|
||||
this.removeListeners(type);
|
||||
this.destroyBehavior(type, behavior);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'update') {
|
||||
behaviors.forEach(config => {
|
||||
const type = typeof config === 'string' ? config : (config as any).type;
|
||||
const behavior = this.behaviorMap.get(type);
|
||||
if (behavior) {
|
||||
behavior.updateConfig(config);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initEvents = () => {
|
||||
Object.values(CANVAS_EVENT_TYPE).forEach(eventName => {
|
||||
console.debug('Listen on canvas: ', eventName);
|
||||
this.graph.canvas.document.addEventListener(eventName, this.handleCanvasEvent);
|
||||
});
|
||||
Object.values(DOM_EVENT_TYPE).forEach(eventName => {
|
||||
this.graph.canvas.getContextService().getDomElement().addEventListener(eventName, this.handleDOMEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private handleCanvasEvent = (gEvent: Event) => {
|
||||
const debug = gEvent.type.includes('over') || gEvent.type.includes('move') ? () => {} : console.debug;
|
||||
|
||||
// Find the Node/Edge/Combo group element.
|
||||
// const itemGroup = findItemGroup(gEvent.target as IElement);
|
||||
// const itemType = itemGroup?.getAttribute('data-item-type') || 'canvas';
|
||||
// const itemId = itemGroup?.getAttribute('data-item-id') || 'CANVAS';
|
||||
|
||||
const itemInfo = getItemInfoFromElement(gEvent.target as IElement);
|
||||
if (!itemInfo) {
|
||||
// This event was triggered from an element which belongs to none of the nodes/edges/canvas.
|
||||
return;
|
||||
}
|
||||
|
||||
// GEvent => G6Event
|
||||
const { itemType, itemId } = itemInfo;
|
||||
const event: IG6GraphEvent = {
|
||||
...gEvent,
|
||||
itemType,
|
||||
itemId,
|
||||
gEvent,
|
||||
// Set currentTarget to this.graph instead of canvas.
|
||||
// Because we add listeners like this: `graph.on('node:click', listener)`.
|
||||
currentTarget: this.graph,
|
||||
};
|
||||
|
||||
// Trigger a dblclick event.
|
||||
if (event.type === 'click' && (event as any as MouseEvent).detail === 2) {
|
||||
this.handleCanvasEvent({
|
||||
...gEvent,
|
||||
type: 'dblclick',
|
||||
});
|
||||
}
|
||||
|
||||
// Canvas Events
|
||||
if (event.itemType === 'canvas') {
|
||||
if (event.type === 'pointermove') {
|
||||
this.handlePointerMove(event);
|
||||
}
|
||||
this.graph.emit(`canvas:${gEvent.type}`, event);
|
||||
debug(`Canvas ${event.type} :`, event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Item Events (Node/Edge)
|
||||
if (itemType === 'node' || itemType === 'edge') {
|
||||
if (event.type === 'pointermove' || event.type === 'pointerleave') {
|
||||
this.handlePointerMove(event);
|
||||
}
|
||||
this.graph.emit(`${itemType}:${gEvent.type}`, event);
|
||||
debug(`Item ${event.type} :`, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit item's pointerleave/pointerenter events when pointer moves on the canvas.
|
||||
*/
|
||||
private handlePointerMove = (event: IG6GraphEvent) => {
|
||||
const prevItemInfo = this.prevItemInfo;
|
||||
const curItemInfo = getItemInfoFromElement(event.target as IElement);
|
||||
if (prevItemInfo?.itemId !== curItemInfo?.itemId) {
|
||||
if (prevItemInfo) {
|
||||
const preType = prevItemInfo.itemType;
|
||||
this.graph.emit(`${preType}:pointerleave`, {
|
||||
...event,
|
||||
type: 'pointerleave',
|
||||
target: prevItemInfo.groupElement,
|
||||
});
|
||||
console.debug(`${preType}:pointerleave`, {
|
||||
...event,
|
||||
type: 'pointerleave',
|
||||
target: prevItemInfo.groupElement,
|
||||
});
|
||||
}
|
||||
if (curItemInfo) {
|
||||
const curType = curItemInfo.itemType;
|
||||
this.graph.emit(`${curType}:pointerenter`, {
|
||||
...event,
|
||||
type: 'pointerenter',
|
||||
target: curItemInfo.groupElement,
|
||||
});
|
||||
console.debug(`${curType}:pointerenter`, {
|
||||
...event,
|
||||
type: 'pointerenter',
|
||||
target: curItemInfo.groupElement,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.prevItemInfo = curItemInfo;
|
||||
};
|
||||
|
||||
private handleDOMEvent = (event: Event) => {
|
||||
this.graph.emit(event.type, event);
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { GraphChange, ID } from '@antv/graphlib';
|
||||
import { ComboModel, IGraph } from '../../types';
|
||||
import { registry } from '../../stdlib';
|
||||
import registry from '../../stdlib';
|
||||
import { getExtension } from '../../util/extension';
|
||||
import { GraphCore } from '../../types/data';
|
||||
import { NodeDisplayModel, NodeEncode, NodeModel, NodeModelData } from '../../types/node';
|
||||
@ -81,7 +81,7 @@ export class ItemController {
|
||||
private getExtensions() {
|
||||
// TODO: user need to config using node/edge/combo types from useLib to spec?
|
||||
const { node, edge, combo } = this.graph.getSpecification();
|
||||
|
||||
|
||||
const nodeTypes = ['circle-node', 'custom-node']; // TODO: WIP
|
||||
const edgeTypes = ['line-edge', 'custom-edge']; // TODO: WIP
|
||||
const comboTypes = ['circle-combo', 'rect-combo']; // TODO: WIP
|
||||
@ -248,7 +248,7 @@ export class ItemController {
|
||||
|
||||
/**
|
||||
* The listener for item state changing.
|
||||
* @param param
|
||||
* @param param
|
||||
* {
|
||||
* ids: ids of the items to be set state
|
||||
* states: state names to set
|
||||
@ -327,7 +327,7 @@ export class ItemController {
|
||||
* @param itemType item's type
|
||||
* @param state state name
|
||||
* @param value state value, true by default
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
public findIdByState(itemType: ITEM_TYPE, state: string, value: string | boolean = true) {
|
||||
const ids = [];
|
||||
|
@ -92,7 +92,7 @@ export default class Graph<B extends BehaviorRegistry> extends EventEmitter impl
|
||||
}
|
||||
this.backgroundCanvas = createCanvas(rendererType, container, width, height, pixelRatio);
|
||||
this.canvas = createCanvas(rendererType, container, width, height, pixelRatio);
|
||||
this.transientCanvas = createCanvas(rendererType, container, width, height, pixelRatio);
|
||||
this.transientCanvas = createCanvas(rendererType, container, width, height, pixelRatio, true, { pointerEvents: 'none' });
|
||||
Promise.all(
|
||||
[this.backgroundCanvas, this.canvas, this.transientCanvas].map((canvas) => canvas.ready),
|
||||
).then(() => (this.canvasReady = true));
|
||||
|
166
packages/g6/src/stdlib/behavior/click-select.ts
Normal file
166
packages/g6/src/stdlib/behavior/click-select.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { Behavior } from '../../types/behavior';
|
||||
import { IG6GraphEvent } from '../../types/event';
|
||||
|
||||
const ALLOWED_TRIGGERS = ['shift', 'ctrl', 'alt', 'meta'] as const;
|
||||
type Trigger = (typeof ALLOWED_TRIGGERS)[number];
|
||||
|
||||
interface ClickSelectOptions {
|
||||
/**
|
||||
* Whether to allow multiple selection.
|
||||
* Defaults to true.
|
||||
* If set to false, `trigger` options will be ignored.
|
||||
*/
|
||||
multiple: boolean;
|
||||
/**
|
||||
* The key to pressed with mouse click to apply multiple selection.
|
||||
* Defaults to `"shift"`.
|
||||
* Could be "shift", "ctrl", "alt", or "meta".
|
||||
*/
|
||||
trigger: Trigger;
|
||||
/**
|
||||
* Item types to be able to select.
|
||||
* Defaults to `["nodes"]`.
|
||||
* Should be an array of "node", "edge", or "combo".
|
||||
*/
|
||||
itemTypes: Array<'node' | 'edge' | 'combo'>;
|
||||
/**
|
||||
* The state to be applied when select.
|
||||
* Defaults to `"selected"`.
|
||||
* Can be set to "active", "highlighted", etc.
|
||||
*/
|
||||
selectedState: 'selected'; // TODO: Enum
|
||||
/**
|
||||
* The event name to trigger when select/unselect an item.
|
||||
*/
|
||||
eventName: string;
|
||||
/**
|
||||
* Whether allow the behavior happen on the current item.
|
||||
*/
|
||||
shouldBegin: (event: IG6GraphEvent) => boolean;
|
||||
/**
|
||||
* Whether to update item state.
|
||||
* If it returns false, you may probably listen to `eventName` and
|
||||
* manage states or data manually
|
||||
*/
|
||||
shouldUpdate: (event: IG6GraphEvent) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: ClickSelectOptions ={
|
||||
multiple: true,
|
||||
trigger: 'shift',
|
||||
selectedState: 'selected',
|
||||
itemTypes: ['node'],
|
||||
eventName: '',
|
||||
shouldBegin: () => true,
|
||||
shouldUpdate: () => true,
|
||||
};
|
||||
|
||||
export class ClickSelect extends Behavior {
|
||||
options: ClickSelectOptions;
|
||||
|
||||
constructor(options: Partial<ClickSelectOptions>) {
|
||||
super(Object.assign({}, DEFAULT_OPTIONS, options));
|
||||
// Validate options
|
||||
if (options.trigger && !ALLOWED_TRIGGERS.includes(options.trigger)) {
|
||||
console.warn(`G6: Invalid trigger option "${options.trigger}" for click-select behavior!`);
|
||||
this.options.trigger = DEFAULT_OPTIONS.trigger;
|
||||
}
|
||||
}
|
||||
|
||||
getEvents = () => {
|
||||
return {
|
||||
'node:click': this.onClick,
|
||||
'canvas:click': this.onCanvasClick,
|
||||
}
|
||||
}
|
||||
|
||||
private isMultipleSelect = (event: MouseEvent) => {
|
||||
if (!this.options.multiple) return false;
|
||||
const key = this.options.trigger;
|
||||
const keyMap: Record<Trigger, boolean> = {
|
||||
shift: event.shiftKey,
|
||||
ctrl: event.ctrlKey,
|
||||
alt: event.altKey,
|
||||
meta: event.metaKey,
|
||||
};
|
||||
return keyMap[this.options.trigger];
|
||||
};
|
||||
|
||||
onClick = (event: IG6GraphEvent) => {
|
||||
if (!this.options.shouldBegin(event)) return;
|
||||
|
||||
// Will not be 'canvas' because this method is listened on node and edge click.
|
||||
const itemType = event.itemType as 'node' | 'edge' | 'combo';
|
||||
const itemId = event.itemId;
|
||||
|
||||
const selectable = this.options.itemTypes.includes(itemType);
|
||||
if (!selectable) return;
|
||||
|
||||
const state = this.options.selectedState;
|
||||
const multiple = this.isMultipleSelect(event as any);
|
||||
// FIXME: should use graph.getItemState() instead
|
||||
const isSelectAction = this.graph.findIdByState(itemType, state).includes(itemId);
|
||||
const action: 'select' | 'unselect' = isSelectAction ? 'unselect' : 'select';
|
||||
|
||||
// Select/Unselect item.
|
||||
if (this.options.shouldUpdate(event)) {
|
||||
if (multiple) {
|
||||
this.graph.setItemState(itemId, state, isSelectAction);
|
||||
} else {
|
||||
// Not multiple, clear all currently selected items
|
||||
const selectedItemIds = [
|
||||
...this.graph.findIdByState('node', state),
|
||||
...this.graph.findIdByState('edge', state),
|
||||
...this.graph.findIdByState('combo', state),
|
||||
];
|
||||
this.graph.setItemState(selectedItemIds, state, false);
|
||||
this.graph.setItemState(itemId, state, isSelectAction);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit an event.
|
||||
if (this.options.eventName) {
|
||||
const selectedNodeIds = this.graph.findIdByState('node', state);
|
||||
const selectedEdgeIds = this.graph.findIdByState('edge', state);
|
||||
const selectedComboIds = this.graph.findIdByState('combo', state);
|
||||
this.graph.emit(this.options.eventName, {
|
||||
action,
|
||||
itemId,
|
||||
itemType,
|
||||
selectedNodeIds,
|
||||
selectedEdgeIds,
|
||||
selectedComboIds,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onCanvasClick = (event: IG6GraphEvent) => {
|
||||
if (!this.options.shouldBegin(event)) return;
|
||||
|
||||
// Find current selected items.
|
||||
const state = this.options.selectedState;
|
||||
const selectedItemIds = [
|
||||
...this.graph.findIdByState('node', state),
|
||||
...this.graph.findIdByState('edge', state),
|
||||
...this.graph.findIdByState('combo', state),
|
||||
];
|
||||
if (!selectedItemIds.length) return;
|
||||
|
||||
// Unselect all items.
|
||||
if (this.options.shouldUpdate(event)) {
|
||||
this.graph.setItemState(selectedItemIds, state, false);
|
||||
}
|
||||
|
||||
// Emit an event.
|
||||
if (this.options.eventName) {
|
||||
this.graph.emit(this.options.eventName, {
|
||||
action: 'unselect',
|
||||
itemId: '',
|
||||
itemType: '',
|
||||
selectedNodeIds: [],
|
||||
selectedEdgeIds: [],
|
||||
selectedComboIds: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -1,22 +1,15 @@
|
||||
import { uniqueId } from '@antv/util';
|
||||
import { Behavior, BehaviorOption, BehaviorSpecification } from '../../types/behavior';
|
||||
// TODO: definition of drag-canvas behavior
|
||||
import { Behavior } from '../../types/behavior';
|
||||
|
||||
/**
|
||||
* TODO: implement drag-canvas behavior
|
||||
*/
|
||||
interface DragCanvasOptions { key?: string, assistKey?: 'ctrl' | 'shift' };
|
||||
export default class DragCanvas extends Behavior {
|
||||
protected key: string;
|
||||
protected options: DragCanvasOptions;
|
||||
constructor(options: DragCanvasOptions) {
|
||||
super(options);
|
||||
}
|
||||
public getKey: () => string = () => {
|
||||
return this.key;
|
||||
|
||||
getEvents() {
|
||||
return {};
|
||||
}
|
||||
public getSpec: () => BehaviorSpecification = () => {
|
||||
return {} as BehaviorSpecification
|
||||
};
|
||||
public getEvents() {
|
||||
return {}
|
||||
}
|
||||
public destroy() { }
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { registry as layoutRegistry } from '@antv/layout';
|
||||
import { Lib } from '../types/stdlib';
|
||||
import DragCanvas from './behavior/drag-canvas';
|
||||
import { ClickSelect } from "./behavior/click-select";
|
||||
import { comboFromNode } from './data/comboFromNode';
|
||||
import { LineEdge } from './item/edge';
|
||||
import { CircleNode } from './item/node';
|
||||
@ -12,7 +13,8 @@ const stdLib = {
|
||||
themes: {},
|
||||
layouts: layoutRegistry,
|
||||
behaviors: {
|
||||
'drag-canvas': DragCanvas
|
||||
'drag-canvas': DragCanvas,
|
||||
'click-select': ClickSelect,
|
||||
},
|
||||
plugins: {},
|
||||
nodes: {
|
||||
@ -35,6 +37,6 @@ const useLib: Lib = {
|
||||
combos: {},
|
||||
};
|
||||
|
||||
const registry = { useLib };
|
||||
export default registry;
|
||||
export { stdLib, registry };
|
||||
const registery = { useLib };
|
||||
export default registery;
|
||||
export { stdLib, registery };
|
||||
|
@ -1,31 +1,30 @@
|
||||
import { uniqueId } from "@antv/util";
|
||||
import { IG6GraphEvent } from "./event";
|
||||
import { IGraph } from "./graph";
|
||||
|
||||
export interface BehaviorOption {
|
||||
key?: string;
|
||||
}
|
||||
/**
|
||||
* Base behavior.
|
||||
* Two implementing ways: getSpec or getEvents
|
||||
* TODO: Support spec mode.
|
||||
*/
|
||||
export abstract class Behavior {
|
||||
protected key: string;
|
||||
protected options: BehaviorOption = {};
|
||||
constructor(options: BehaviorOption) {
|
||||
this.key = options.key || uniqueId();
|
||||
graph: IGraph;
|
||||
options: any;
|
||||
constructor(options: any) {
|
||||
this.options = options;
|
||||
};
|
||||
public getKey = () => this.key;
|
||||
abstract getSpec(): BehaviorSpecification;
|
||||
abstract getEvents(): {
|
||||
[eventName: string]: string
|
||||
[eventName: string]: (event: IG6GraphEvent) => void;
|
||||
};
|
||||
public updateConfig = (options: BehaviorOption) => {
|
||||
updateConfig = (options: any) => {
|
||||
this.options = Object.assign(this.options, options);
|
||||
}
|
||||
abstract destroy(): void;
|
||||
destroy() {};
|
||||
}
|
||||
|
||||
/** Behavior regisry table.
|
||||
/** Behavior registry table.
|
||||
* @example { 'drag-node': DragNodeBehavior, 'my-drag-node': MyDragNodeBehavior }
|
||||
*/
|
||||
export interface BehaviorRegistry {
|
||||
@ -47,4 +46,4 @@ export type BehaviorObjectOptionsOf<B extends BehaviorRegistry = {}> = {
|
||||
/**
|
||||
* TODO: interaction specification
|
||||
*/
|
||||
export interface BehaviorSpecification { }
|
||||
export interface BehaviorSpecification { }
|
||||
|
45
packages/g6/src/types/event.ts
Normal file
45
packages/g6/src/types/event.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { ID } from "@antv/graphlib";
|
||||
import { IGraph } from "./graph";
|
||||
|
||||
/** Event type enum */
|
||||
export enum CANVAS_EVENT_TYPE {
|
||||
// Pointer events
|
||||
'pointerover' = 'pointerover',
|
||||
'pointerenter' = 'pointerenter',
|
||||
'pointerdown' = 'pointerdown',
|
||||
'pointermove' = 'pointermove',
|
||||
'pointerup' = 'pointerup',
|
||||
'pointerleave' = 'pointerleave',
|
||||
|
||||
// Mouse events
|
||||
'click' = 'click',
|
||||
'wheel' = 'wheel',
|
||||
|
||||
// 'dragstart',
|
||||
// 'drag',
|
||||
// 'dragend',
|
||||
// 'dragenter',
|
||||
// 'dragleave',
|
||||
// 'dragover',
|
||||
// 'dragout',
|
||||
// 'drop',
|
||||
}
|
||||
|
||||
export enum DOM_EVENT_TYPE {
|
||||
'focus' = 'focus',
|
||||
'blur' = 'blur',
|
||||
'keyup' = 'keyup',
|
||||
'keydown' = 'keydown',
|
||||
'keypress' = 'keypress',
|
||||
}
|
||||
|
||||
/** Event type union */
|
||||
export type ICanvasEventType = `${CANVAS_EVENT_TYPE}`;
|
||||
|
||||
export interface IG6GraphEvent extends Omit<Event, 'currentTarget'> {
|
||||
currentTarget: IGraph;
|
||||
itemType: 'node' | 'edge' | 'combo' | 'canvas';
|
||||
itemId: ID;
|
||||
/** Original event emitted by G */
|
||||
gEvent: Event;
|
||||
};
|
@ -6,13 +6,13 @@ import { isString } from '@antv/util';
|
||||
|
||||
/**
|
||||
* Create a canvas
|
||||
* @param { 'canvas' | 'svg' | 'webgl' } rendererType
|
||||
* @param {string | HTMLElement} container
|
||||
* @param { 'canvas' | 'svg' | 'webgl' } rendererType
|
||||
* @param {string | HTMLElement} container
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {number} pixelRatio optional
|
||||
* @param {boolean} customCanvasTag whether create a <canvas /> for multiple canvas under the container
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
export const createCanvas = (
|
||||
rendererType: 'canvas' | 'svg' | 'webgl',
|
||||
@ -20,7 +20,8 @@ export const createCanvas = (
|
||||
width: number,
|
||||
height: number,
|
||||
pixelRatio?: number,
|
||||
customCanvasTag: boolean = true
|
||||
customCanvasTag: boolean = true,
|
||||
style: any = {},
|
||||
) => {
|
||||
let Renderer;
|
||||
switch (rendererType.toLowerCase()) {
|
||||
@ -42,6 +43,7 @@ export const createCanvas = (
|
||||
canvasTag.style.width = `${width}px`;
|
||||
canvasTag.style.height = `${height}px`;
|
||||
canvasTag.style.position = 'fixed';
|
||||
Object.assign(canvasTag.style, style);
|
||||
const containerDOM = isString(container) ? document.getElementById('container') : container;
|
||||
containerDOM!.appendChild(canvasTag);
|
||||
return new Canvas({
|
||||
|
34
packages/g6/src/util/event.ts
Normal file
34
packages/g6/src/util/event.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { IElement } from "@antv/g";
|
||||
import { ID } from "@antv/graphlib";
|
||||
|
||||
export type ItemInfo = {
|
||||
itemType: 'canvas' | 'node' | 'edge' | 'combo';
|
||||
itemId: ID;
|
||||
groupElement: IElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an element, which might be a child shape of a node/edge,
|
||||
* get its belonging item.
|
||||
*/
|
||||
export const getItemInfoFromElement = (element: IElement): ItemInfo | null => {
|
||||
if (element.nodeName === 'document') {
|
||||
return {
|
||||
itemType: 'canvas',
|
||||
itemId: 'CANVAS',
|
||||
groupElement: element,
|
||||
};
|
||||
}
|
||||
|
||||
let parent = element;
|
||||
while (parent && !parent.getAttribute('data-item-type')) {
|
||||
parent = element.parentElement;
|
||||
}
|
||||
if (!parent) return null;
|
||||
|
||||
return {
|
||||
itemType: parent.getAttribute('data-item-type'),
|
||||
itemId: parent.getAttribute('data-item-id'),
|
||||
groupElement: parent,
|
||||
};
|
||||
}
|
30
packages/g6/tests/unit/click-select-spec.ts
Normal file
30
packages/g6/tests/unit/click-select-spec.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import G6 from '../../src/index';
|
||||
import { Behavior } from '../../src/types/behavior';
|
||||
import { extend } from '../../src/util/extend';
|
||||
const container = document.createElement('div');
|
||||
document.querySelector('body').appendChild(container);
|
||||
|
||||
|
||||
describe('click-select', () => {
|
||||
it('x', () => {
|
||||
const graph = new G6.Graph({
|
||||
container,
|
||||
width: 500,
|
||||
height: 500,
|
||||
type: 'graph',
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: 'node1', data: { x: 100, y: 200, keyShape: { fill: "#0f0" } } },
|
||||
{ id: 'node2', data: { x: 200, y: 250, keyShape: { fill: "#f00" } } }
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge1', source: 'node1', target: 'node2', data: { keyShape: { stroke: '#00f', lineWidth: 5 } } }
|
||||
]
|
||||
},
|
||||
modes: {
|
||||
default: [ 'click-select' ],
|
||||
},
|
||||
});
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user