diff --git a/packages/g6/__tests__/demo/case/behavior-drag-canvas.ts b/packages/g6/__tests__/demo/case/behavior-drag-canvas.ts
new file mode 100644
index 00000000000..fc70d621a87
--- /dev/null
+++ b/packages/g6/__tests__/demo/case/behavior-drag-canvas.ts
@@ -0,0 +1,36 @@
+import { Graph } from '@/src';
+import data from '@@/dataset/cluster.json';
+import type { STDTestCase } from '../types';
+
+export const behaviorDragCanvas: STDTestCase = async (context) => {
+ const { canvas, animation } = context;
+ const graph = new Graph({
+ animation,
+ container: canvas,
+ data,
+ layout: {
+ type: 'd3force',
+ },
+ node: {
+ style: {
+ size: 20,
+ },
+ },
+ behaviors: [
+ 'drag-canvas',
+ {
+ type: 'drag-canvas',
+ trigger: {
+ up: ['ArrowUp'],
+ down: ['ArrowDown'],
+ right: ['ArrowRight'],
+ left: ['ArrowLeft'],
+ },
+ },
+ ],
+ });
+
+ await graph.render();
+
+ return graph;
+};
diff --git a/packages/g6/__tests__/demo/case/index.ts b/packages/g6/__tests__/demo/case/index.ts
index 326f6c7c6fa..6a4d211cee9 100644
--- a/packages/g6/__tests__/demo/case/index.ts
+++ b/packages/g6/__tests__/demo/case/index.ts
@@ -1 +1,2 @@
+export * from './behavior-drag-canvas';
export * from './behavior-zoom-canvas';
diff --git a/packages/g6/__tests__/snapshots/behaviors/behavior-drag-canvas/behavior-drag-canvas.svg b/packages/g6/__tests__/snapshots/behaviors/behavior-drag-canvas/behavior-drag-canvas.svg
new file mode 100644
index 00000000000..630cf4a50c3
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/behaviors/behavior-drag-canvas/behavior-drag-canvas.svg
@@ -0,0 +1,2511 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/unit/behaviors/behavior-drag-canvas.spec.ts b/packages/g6/__tests__/unit/behaviors/behavior-drag-canvas.spec.ts
new file mode 100644
index 00000000000..a550803de94
--- /dev/null
+++ b/packages/g6/__tests__/unit/behaviors/behavior-drag-canvas.spec.ts
@@ -0,0 +1,84 @@
+import { CommonEvent, type Graph } from '@/src';
+import { behaviorDragCanvas } from '@@/demo/case';
+import { createDemoGraph } from '@@/utils';
+import { isObject } from '@antv/util';
+
+describe('behavior drag canvas', () => {
+ let graph: Graph;
+
+ beforeAll(async () => {
+ graph = await createDemoGraph(behaviorDragCanvas, { animation: false });
+ });
+
+ it('default status', () => {
+ expect(graph.getBehaviors()).toEqual([
+ 'drag-canvas',
+ {
+ type: 'drag-canvas',
+ trigger: {
+ up: ['ArrowUp'],
+ down: ['ArrowDown'],
+ right: ['ArrowRight'],
+ left: ['ArrowLeft'],
+ },
+ },
+ ]);
+ });
+
+ it('arrow up', () => {
+ const [x, y] = graph.getPosition();
+ graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowUp' });
+ graph.emit(CommonEvent.KEY_UP, { key: 'ArrowUp' });
+
+ expect(graph.getPosition()).toBeCloseTo([x, y - 10]);
+ });
+
+ it('arrow down', () => {
+ const [x, y] = graph.getPosition();
+ graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowDown' });
+ graph.emit(CommonEvent.KEY_UP, { key: 'ArrowDown' });
+ expect(graph.getPosition()).toBeCloseTo([x, y + 10]);
+ });
+
+ it('arrow left', () => {
+ const [x, y] = graph.getPosition();
+ graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowLeft' });
+ graph.emit(CommonEvent.KEY_UP, { key: 'ArrowLeft' });
+ expect(graph.getPosition()).toBeCloseTo([x - 10, y]);
+ });
+
+ it('arrow right', () => {
+ const [x, y] = graph.getPosition();
+ graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' });
+ graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' });
+ expect(graph.getPosition()).toBeCloseTo([x + 10, y]);
+ });
+
+ it('drag', () => {
+ const [x, y] = graph.getPosition();
+ graph.emit(CommonEvent.DRAG, { movement: { x: 10, y: 10 }, targetType: 'canvas' });
+ expect(graph.getPosition()).toBeCloseTo([x + 10, y + 10]);
+ });
+
+ it('sensitivity', async () => {
+ graph.setBehaviors((behaviors) =>
+ behaviors.map((behavior) => {
+ if (isObject(behavior)) {
+ return { ...behavior, sensitivity: 20 };
+ }
+ return behavior;
+ }),
+ );
+
+ const [x, y] = graph.getPosition();
+ graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' });
+ graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' });
+ expect(graph.getPosition()).toBeCloseTo([x + 20, y]);
+
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename);
+ });
+
+ it('destroy', () => {
+ graph.destroy();
+ });
+});
diff --git a/packages/g6/__tests__/unit/utils/behavior.spec.ts b/packages/g6/__tests__/unit/utils/behavior.spec.ts
deleted file mode 100644
index 0ce7bb2a18b..00000000000
--- a/packages/g6/__tests__/unit/utils/behavior.spec.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { BehaviorOptions } from '@/src';
-import { parseBehaviors } from '@/src/utils/behaviors';
-
-describe('behavior', () => {
- it('parseBehaviors', () => {
- expect(parseBehaviors([])).toEqual([]);
-
- const options: BehaviorOptions = [
- 'drag-node',
- { type: 'drag-canvas' },
- { type: 'shortcut', key: 'shortcut-zoom-in' },
- { type: 'shortcut', key: 'shortcut-zoom-out' },
- 'scroll-canvas',
- 'scroll-canvas',
- ];
-
- expect(parseBehaviors(options)).toEqual([
- { type: 'drag-node', key: 'behavior-drag-node-0' },
- { type: 'drag-canvas', key: 'behavior-drag-canvas-0' },
- { type: 'shortcut', key: 'shortcut-zoom-in' },
- { type: 'shortcut', key: 'shortcut-zoom-out' },
- { type: 'scroll-canvas', key: 'behavior-scroll-canvas-0' },
- { type: 'scroll-canvas', key: 'behavior-scroll-canvas-1' },
- ]);
- });
-});
diff --git a/packages/g6/__tests__/unit/utils/module.spec.ts b/packages/g6/__tests__/unit/utils/module.spec.ts
new file mode 100644
index 00000000000..1c97778de3f
--- /dev/null
+++ b/packages/g6/__tests__/unit/utils/module.spec.ts
@@ -0,0 +1,54 @@
+import { BehaviorOptions, WidgetOptions } from '@/src';
+import { parseModules } from '@/src/utils/module';
+
+describe('module', () => {
+ it('parse behavior module', () => {
+ expect(parseModules('behavior', [])).toEqual([]);
+
+ const options: BehaviorOptions = [
+ 'drag-node',
+ { type: 'drag-canvas' },
+ { type: 'shortcut', key: 'shortcut-zoom-in' },
+ { type: 'shortcut', key: 'shortcut-zoom-out' },
+ 'scroll-canvas',
+ 'scroll-canvas',
+ ];
+
+ expect(parseModules('behavior', options)).toEqual([
+ { type: 'drag-node', key: 'behavior-drag-node-0' },
+ { type: 'drag-canvas', key: 'behavior-drag-canvas-0' },
+ { type: 'shortcut', key: 'shortcut-zoom-in' },
+ { type: 'shortcut', key: 'shortcut-zoom-out' },
+ { type: 'scroll-canvas', key: 'behavior-scroll-canvas-0' },
+ { type: 'scroll-canvas', key: 'behavior-scroll-canvas-1' },
+ ]);
+ });
+
+ it('parseWidgets', () => {
+ expect(parseModules('widget', [])).toEqual([]);
+
+ const options: WidgetOptions = [
+ 'minimap',
+ { key: 'my-tooltip', type: 'tooltip' },
+ { type: 'tooltip' },
+ {
+ type: 'menu',
+ key: 'my-context-menu',
+ trigger: 'contextmenu',
+ },
+ 'minimap',
+ ];
+
+ expect(parseModules('widget', options)).toEqual([
+ { type: 'minimap', key: 'widget-minimap-0' },
+ { type: 'tooltip', key: 'my-tooltip' },
+ { type: 'tooltip', key: 'widget-tooltip-0' },
+ {
+ type: 'menu',
+ key: 'my-context-menu',
+ trigger: 'contextmenu',
+ },
+ { type: 'minimap', key: 'widget-minimap-1' },
+ ]);
+ });
+});
diff --git a/packages/g6/__tests__/unit/utils/shortcut.spec.ts b/packages/g6/__tests__/unit/utils/shortcut.spec.ts
new file mode 100644
index 00000000000..585316b570c
--- /dev/null
+++ b/packages/g6/__tests__/unit/utils/shortcut.spec.ts
@@ -0,0 +1,68 @@
+import { CommonEvent } from '@/src';
+import { Shortcut } from '@/src/utils/shortcut';
+import EventEmitter from '@antv/event-emitter';
+
+describe('shortcut', () => {
+ const emitter = new EventEmitter();
+
+ const shortcut = new Shortcut(emitter);
+
+ it('bind and unbind', () => {
+ const controlEqual = jest.fn();
+ const controlMinus = jest.fn();
+ shortcut.bind(['Control', '='], controlEqual);
+ shortcut.bind(['Control', '-'], controlMinus);
+
+ emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' });
+ emitter.emit(CommonEvent.KEY_DOWN, { key: '=' });
+ emitter.emit(CommonEvent.KEY_UP, { key: 'Control' });
+ emitter.emit(CommonEvent.KEY_UP, { key: '=' });
+
+ expect(controlEqual).toHaveBeenCalledTimes(1);
+ expect(controlMinus).toHaveBeenCalledTimes(0);
+
+ emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' });
+ emitter.emit(CommonEvent.KEY_DOWN, { key: '-' });
+ emitter.emit(CommonEvent.KEY_UP, { key: 'Control' });
+ emitter.emit(CommonEvent.KEY_UP, { key: '-' });
+
+ expect(controlEqual).toHaveBeenCalledTimes(1);
+ expect(controlMinus).toHaveBeenCalledTimes(1);
+
+ emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' });
+ emitter.emit(CommonEvent.KEY_DOWN, { key: '=' });
+ emitter.emit(CommonEvent.KEY_UP, { key: '=' });
+ emitter.emit(CommonEvent.KEY_DOWN, { key: '-' });
+ emitter.emit(CommonEvent.KEY_UP, { key: '-' });
+ emitter.emit(CommonEvent.KEY_UP, { key: 'Control' });
+
+ expect(controlEqual).toHaveBeenCalledTimes(2);
+ expect(controlMinus).toHaveBeenCalledTimes(2);
+
+ shortcut.unbind(['Control', '='], controlEqual);
+ shortcut.unbind(['Control', '-']);
+
+ emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' });
+ emitter.emit(CommonEvent.KEY_DOWN, { key: '=' });
+ emitter.emit(CommonEvent.KEY_UP, { key: '=' });
+ emitter.emit(CommonEvent.KEY_DOWN, { key: '-' });
+ emitter.emit(CommonEvent.KEY_UP, { key: '-' });
+ emitter.emit(CommonEvent.KEY_UP, { key: 'Control' });
+
+ expect(controlEqual).toHaveBeenCalledTimes(2);
+ expect(controlMinus).toHaveBeenCalledTimes(2);
+ });
+
+ it('wheel', () => {
+ const wheel = jest.fn();
+ shortcut.bind(['Control', 'wheel'], wheel);
+
+ emitter.emit(CommonEvent.WHEEL, { deltaX: 0, deltaY: 10 });
+ expect(wheel).toHaveBeenCalledTimes(0);
+
+ emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' });
+ emitter.emit(CommonEvent.WHEEL, { deltaX: 0, deltaY: 10 });
+ expect(wheel).toHaveBeenCalledTimes(1);
+ expect(wheel.mock.calls[0][0].deltaY).toBe(10);
+ });
+});
diff --git a/packages/g6/__tests__/utils/to-be-close-to.ts b/packages/g6/__tests__/utils/to-be-close-to.ts
index 8b7760ca895..e73eea96745 100644
--- a/packages/g6/__tests__/utils/to-be-close-to.ts
+++ b/packages/g6/__tests__/utils/to-be-close-to.ts
@@ -33,16 +33,9 @@ declare global {
expect.extend({
toBeCloseTo: (received: Digital, expected: Digital, numDigits?: number) => {
const pass = toBeCloseTo(received, expected, numDigits);
- if (pass) {
- return {
- message: () => `expected ${received} not to be close to ${expected}`,
- pass: true,
- };
- } else {
- return {
- message: () => `expected ${received} to be close to ${expected}`,
- pass: false,
- };
- }
+ return {
+ message: () => `expected: \x1b[32m${received}\n\x1b[31mreceived: ${expected}\x1b[0m`,
+ pass,
+ };
},
});
diff --git a/packages/g6/src/behaviors/base-behavior.ts b/packages/g6/src/behaviors/base-behavior.ts
index e9d3e70c338..7319bf36883 100644
--- a/packages/g6/src/behaviors/base-behavior.ts
+++ b/packages/g6/src/behaviors/base-behavior.ts
@@ -1,49 +1,6 @@
-import type EventEmitter from '@antv/event-emitter';
-import type { RuntimeContext } from '../runtime/types';
import type { CustomBehaviorOption } from '../spec/behavior';
-import type { Listener } from '../types';
+import { BaseModule } from '../utils/module';
export type BaseBehaviorOptions = CustomBehaviorOption;
-export abstract class BaseBehavior {
- protected context: RuntimeContext;
-
- protected options: Required;
-
- public events: [EventEmitter | HTMLElement, string, Listener][] = [];
-
- public destroyed = false;
-
- public get defaultOptions(): Partial {
- return {};
- }
-
- constructor(context: RuntimeContext, options: T) {
- this.context = context;
- this.options = Object.assign({}, this.defaultOptions, options) as Required;
- }
-
- public update(options: Partial) {
- this.options = Object.assign(this.options, options);
- }
-
- public addEventListener(emitter: EventEmitter | HTMLElement, eventName: string, listener: Listener) {
- if (emitter instanceof HTMLElement) emitter.addEventListener(eventName, listener);
- else emitter.on(eventName, listener);
- this.events.push([emitter, eventName, listener]);
- }
-
- public destroy() {
- this.events.forEach(([emitter, event, listener]) => {
- if (emitter instanceof HTMLElement) emitter.removeEventListener(event, listener);
- else emitter.off(event, listener);
- });
-
- // @ts-expect-error force delete
- delete this.context;
- // @ts-expect-error force delete
- delete this.options;
-
- this.destroyed = true;
- }
-}
+export abstract class BaseBehavior extends BaseModule {}
diff --git a/packages/g6/src/behaviors/drag-canvas.ts b/packages/g6/src/behaviors/drag-canvas.ts
new file mode 100644
index 00000000000..d485cd54e17
--- /dev/null
+++ b/packages/g6/src/behaviors/drag-canvas.ts
@@ -0,0 +1,121 @@
+import type { Cursor, FederatedMouseEvent } from '@antv/g';
+import { isFunction, isObject } from '@antv/util';
+import { CanvasEvent } from '../constants';
+import { RuntimeContext } from '../runtime/types';
+import type { BehaviorEvent, Point, ViewportAnimationEffectTiming } from '../types';
+import type { ShortcutKey } from '../utils/shortcut';
+import { Shortcut } from '../utils/shortcut';
+import { multiply } from '../utils/vector';
+import type { BaseBehaviorOptions } from './base-behavior';
+import { BaseBehavior } from './base-behavior';
+
+export interface DragCanvasOptions extends BaseBehaviorOptions {
+ /**
+ * 是否启用缩放动画,仅在使用按键移动时有效
+ *
+ * Whether to enable the animation of zooming, only valid when using key movement
+ */
+ animation?: ViewportAnimationEffectTiming;
+ /**
+ * 是否启用拖拽画布的功能
+ *
+ * Whether to enable the function of dragging the canvas
+ */
+ enable?: boolean | ((event: BehaviorEvent | BehaviorEvent) => boolean);
+ /**
+ * 触发拖拽的方式,默认使用指针按下拖拽
+ *
+ * The way to trigger dragging, default to dragging with the pointer pressed
+ */
+ trigger?: CombinationKey;
+ /**
+ * 触发一次按键移动的距离
+ *
+ * The distance of a single key movement
+ */
+ sensitivity?: number;
+ /**
+ * 完成拖拽时的回调
+ *
+ * Callback when dragging is completed
+ */
+ onfinish?: () => void;
+}
+
+type CombinationKey = {
+ up: ShortcutKey;
+ down: ShortcutKey;
+ left: ShortcutKey;
+ right: ShortcutKey;
+};
+
+export class DragCanvas extends BaseBehavior {
+ static defaultOptions: Partial = {
+ enable: true,
+ sensitivity: 10,
+ };
+
+ private shortcut: Shortcut;
+
+ private defaultCursor: Cursor;
+
+ private get animation() {
+ return this.context.options.animation ? this.options.animation : false;
+ }
+
+ constructor(context: RuntimeContext, options: DragCanvasOptions) {
+ super(context, Object.assign({}, DragCanvas.defaultOptions, options));
+
+ this.shortcut = new Shortcut(context.graph);
+
+ this.bindEvents();
+ this.defaultCursor = this.context.canvas.getConfig().cursor || 'default';
+ context.canvas.setCursor('grab');
+ }
+
+ private bindEvents() {
+ const { trigger } = this.options;
+ this.shortcut.unbindAll();
+ const { graph } = this.context;
+
+ if (isObject(trigger)) {
+ graph.off(CanvasEvent.DRAG, this.onDrag);
+ const { up = [], down = [], left = [], right = [] } = trigger;
+
+ this.shortcut.bind(up, (event) => this.translate([0, 1], event));
+ this.shortcut.bind(down, (event) => this.translate([0, -1], event));
+ this.shortcut.bind(left, (event) => this.translate([1, 0], event));
+ this.shortcut.bind(right, (event) => this.translate([-1, 0], event));
+ } else {
+ graph.on(CanvasEvent.DRAG, this.onDrag);
+ }
+ }
+
+ private onDrag = (event: BehaviorEvent) => {
+ if (event.targetType === 'canvas') {
+ this.context.viewport?.translate(
+ { mode: 'relative', value: [event.movement.x, event.movement.y] },
+ this.animation,
+ );
+ }
+ };
+
+ private translate(value: Point, event: BehaviorEvent | BehaviorEvent) {
+ if (!this.validate(event)) return;
+ const { sensitivity } = this.options;
+ const delta = sensitivity * -1;
+ this.context.viewport?.translate({ mode: 'relative', value: multiply(value, [delta, delta]) }, this.animation);
+ }
+
+ private validate(event: BehaviorEvent | BehaviorEvent) {
+ if (this.destroyed) return false;
+ const { enable } = this.options;
+ if (isFunction(enable)) return enable(event);
+ return !!enable;
+ }
+
+ public destroy(): void {
+ this.context.canvas.setCursor(this.defaultCursor);
+ super.destroy();
+ }
+}
diff --git a/packages/g6/src/behaviors/index.ts b/packages/g6/src/behaviors/index.ts
index 1b6078083c2..7afe8b36729 100644
--- a/packages/g6/src/behaviors/index.ts
+++ b/packages/g6/src/behaviors/index.ts
@@ -1,7 +1,9 @@
import type { ZoomCanvasOptions } from './zoom-canvas';
export { BaseBehavior } from './base-behavior';
+export { DragCanvas } from './drag-canvas';
export { ZoomCanvas } from './zoom-canvas';
export type { BaseBehaviorOptions } from './base-behavior';
+export type { DragCanvasOptions } from './drag-canvas';
export type BuiltInBehaviorOptions = ZoomCanvasOptions;
diff --git a/packages/g6/src/behaviors/types.ts b/packages/g6/src/behaviors/types.ts
index cc8f57dfad1..114dd665821 100644
--- a/packages/g6/src/behaviors/types.ts
+++ b/packages/g6/src/behaviors/types.ts
@@ -1,4 +1,6 @@
import type { BaseBehavior } from './base-behavior';
+import type { DragCanvasOptions } from './drag-canvas';
+import type { ZoomCanvasOptions } from './zoom-canvas';
-export type BuiltInBehaviorOptions = { type: 'unset' };
+export type BuiltInBehaviorOptions = DragCanvasOptions | ZoomCanvasOptions;
export type Behavior = BaseBehavior;
diff --git a/packages/g6/src/behaviors/zoom-canvas.ts b/packages/g6/src/behaviors/zoom-canvas.ts
index cb1fb2b9c31..196b70be7cb 100644
--- a/packages/g6/src/behaviors/zoom-canvas.ts
+++ b/packages/g6/src/behaviors/zoom-canvas.ts
@@ -1,7 +1,9 @@
-import { isArray, isEqual, isObject } from '@antv/util';
-import { CommonEvent } from '../constants';
+import { isArray, isFunction, isObject } from '@antv/util';
+import { CanvasEvent } from '../constants';
import type { RuntimeContext } from '../runtime/types';
-import type { BehaviorEvent, Loose, ViewportAnimationEffectTiming } from '../types';
+import type { BehaviorEvent, ViewportAnimationEffectTiming } from '../types';
+import type { ShortcutKey } from '../utils/shortcut';
+import { Shortcut } from '../utils/shortcut';
import type { BaseBehaviorOptions } from './base-behavior';
import { BaseBehavior } from './base-behavior';
@@ -17,23 +19,21 @@ export interface ZoomCanvasOptions extends BaseBehaviorOptions {
*
* Whether to enable the function of zooming the canvas
*/
- enable?: boolean | ((event: BehaviorEvent) => boolean);
+ enable?: boolean | ((event: BehaviorEvent | BehaviorEvent) => boolean);
/**
* 触发缩放的方式
*
* The way to trigger zoom
* @description
*
- * - 'wheel':滚动鼠标滚轮或触摸板时触发缩放
- * - 数组:组合快捷键,例如 ['ctrl'] 表示按住 ctrl 键滚动鼠标滚轮时触发缩放
- * - 对象:缩放快捷键,例如 { zoomIn: ['ctrl', '+'], zoomOut: ['ctrl', '-'], reset: ['ctrl', '0'] }
+ * - 数组:组合快捷键,默认使用滚轮缩放,['Control'] 表示按住 Control 键滚动鼠标滚轮时触发缩放
+ * - 对象:缩放快捷键,例如 { zoomIn: ['Control', '+'], zoomOut: ['Control', '-'], reset: ['Control', '0'] }
*
*
- * - 'wheel': Trigger zoom when scrolling the mouse wheel or touchpad
- * - Array: Combination shortcut keys, such as ['ctrl'] means zooming when scrolling the mouse wheel while holding down the ctrl key
- * - Object: Zoom shortcut keys, such as { zoomIn: ['ctrl', '+'], zoomOut: ['ctrl', '-'], reset: ['ctrl', '0'] }
+ * - Array: Combination shortcut key, default to zoom in and out with the mouse wheel, ['Control'] means zooming when holding down the Control key and scrolling the mouse wheel
+ * - Object: Zoom shortcut key, such as { zoomIn: ['Control', '+'], zoomOut: ['Control', '-'], reset: ['Control', '0'] }
*/
- trigger?: Loose | string[] | CombinationKey;
+ trigger?: ShortcutKey | CombinationKey;
/**
* 缩放灵敏度
*
@@ -49,86 +49,62 @@ export interface ZoomCanvasOptions extends BaseBehaviorOptions {
}
type CombinationKey = {
- zoomIn: string[];
- zoomOut: string[];
- reset: string[];
+ zoomIn: ShortcutKey;
+ zoomOut: ShortcutKey;
+ reset: ShortcutKey;
};
export class ZoomCanvas extends BaseBehavior {
- private preconditionKey?: string[];
-
- private recordKey = new Set();
-
- private combinationKey: CombinationKey = {
- zoomIn: [],
- zoomOut: [],
- reset: [],
+ static defaultOptions: Partial = {
+ animation: { duration: 200 },
+ enable: true,
+ sensitivity: 1,
+ trigger: [],
};
+ private shortcut: Shortcut;
+
private get animation() {
return this.context.options.animation ? this.options.animation : false;
}
- public get defaultOptions(): Partial {
- return { animation: { duration: 200 }, enable: true, sensitivity: 1, trigger: CommonEvent.WHEEL };
- }
-
constructor(context: RuntimeContext, options: ZoomCanvasOptions) {
- super(context, options);
+ super(context, Object.assign({}, ZoomCanvas.defaultOptions, options));
- if (isArray(this.options.trigger)) {
- this.preconditionKey = this.options.trigger;
- }
+ this.shortcut = new Shortcut(context.graph);
- if (isObject(this.options.trigger)) {
- this.combinationKey = this.options.trigger as CombinationKey;
- }
+ this.bindEvents();
+ }
+ public update(options: Partial): void {
+ super.update(options);
this.bindEvents();
}
private bindEvents() {
- const { graph } = this.context;
-
- // wheel 触发和组合键触发需要监听 wheel 事件 / Combination key trigger and wheel trigger need to listen to the wheel event
- if (this.options.trigger === CommonEvent.WHEEL || isArray(this.options.trigger)) {
- this.preventDefault(CommonEvent.WHEEL);
- this.addEventListener(graph, CommonEvent.WHEEL, this.onWheel.bind(this));
+ const { trigger } = this.options;
+ this.shortcut.unbindAll();
+
+ if (isArray(trigger)) {
+ if (trigger.includes(CanvasEvent.WHEEL)) {
+ this.preventDefault(CanvasEvent.WHEEL);
+ }
+ this.shortcut.bind([...trigger, CanvasEvent.WHEEL], this.onWheel);
}
- if (isObject(this.options.trigger)) {
- this.addEventListener(graph, CommonEvent.KEY_DOWN, this.onKeydown.bind(this));
- this.addEventListener(graph, CommonEvent.KEY_UP, this.onKeyup.bind(this));
+ if (isObject(trigger)) {
+ const { zoomIn = [], zoomOut = [], reset = [] } = trigger as CombinationKey;
+ this.shortcut.bind(zoomIn, (event) => this.zoom(1, event));
+ this.shortcut.bind(zoomOut, (event) => this.zoom(-1, event));
+ this.shortcut.bind(reset, this.onReset);
}
}
- private onWheel(event: BehaviorEvent) {
+ private onWheel = (event: BehaviorEvent) => {
const { deltaX, deltaY } = event;
const delta = -(deltaY || deltaX);
this.zoom(delta, event);
- }
-
- private onKeydown(event: BehaviorEvent) {
- const { key } = event;
- this.recordKey.add(key);
-
- if (this.isTrigger(this.combinationKey.zoomIn)) {
- this.zoom(1, event);
- } else if (this.isTrigger(this.combinationKey.zoomOut)) {
- this.zoom(-1, event);
- } else if (this.isTrigger(this.combinationKey.reset)) {
- this.reset();
- }
- }
-
- private onKeyup(event: KeyboardEvent) {
- const { key } = event;
- this.recordKey.delete(key);
- }
-
- private isTrigger(keys: string[]) {
- return isEqual(Array.from(this.recordKey), keys);
- }
+ };
/**
* 缩放画布
@@ -137,7 +113,7 @@ export class ZoomCanvas extends BaseBehavior {
* @param value - 缩放值, > 0 放大, < 0 缩小 | Zoom value, > 0 zoom in, < 0 zoom out
* @param event - 事件对象 | Event object
*/
- private async zoom(value: number, event: BehaviorEvent | BehaviorEvent) {
+ private zoom = async (value: number, event: BehaviorEvent | BehaviorEvent) => {
if (!this.validate(event)) return;
const { viewport } = this.context;
if (!viewport) return;
@@ -148,26 +124,24 @@ export class ZoomCanvas extends BaseBehavior {
await viewport.zoom({ mode: 'absolute', value: zoom + diff }, this.animation);
onfinish?.();
- }
+ };
- private async reset() {
+ private onReset = async () => {
const { viewport } = this.context;
await viewport?.zoom({ mode: 'absolute', value: 1 }, this.animation);
- }
+ };
private validate(event: BehaviorEvent | BehaviorEvent) {
- if (this.preconditionKey && !isEqual(this.preconditionKey, Array.from(this.recordKey))) return false;
-
+ if (this.destroyed) return false;
const { enable } = this.options;
- if (typeof enable === 'function' && !enable(event)) return false;
- if (enable === false) return false;
- return true;
+ if (isFunction(enable)) return enable(event);
+ return !!enable;
}
private preventDefault(eventName: string) {
const listener = (e: Event) => e.preventDefault();
const container = this.context.canvas.getContainer();
if (!container) return;
- this.addEventListener(container, eventName, listener);
+ container.addEventListener(eventName, listener);
}
}
diff --git a/packages/g6/src/constants/events/canvas.ts b/packages/g6/src/constants/events/canvas.ts
index b1dfcddae14..7e72f4ea913 100644
--- a/packages/g6/src/constants/events/canvas.ts
+++ b/packages/g6/src/constants/events/canvas.ts
@@ -1,4 +1,4 @@
-export enum CanvasEvent {
+export const enum CanvasEvent {
/** 点击时触发 | Triggered when click */
CLICK = 'click',
/** 双击时触发 | Triggered when double click */
diff --git a/packages/g6/src/constants/events/common.ts b/packages/g6/src/constants/events/common.ts
index bf50608107e..d413882f0d0 100644
--- a/packages/g6/src/constants/events/common.ts
+++ b/packages/g6/src/constants/events/common.ts
@@ -1,4 +1,4 @@
-export enum CommonEvent {
+export const enum CommonEvent {
/** 点击时触发 | Triggered when click */
CLICK = 'click',
/** 双击时触发 | Triggered when double click */
diff --git a/packages/g6/src/constants/events/container.ts b/packages/g6/src/constants/events/container.ts
index 99467d3d521..d2302c20210 100644
--- a/packages/g6/src/constants/events/container.ts
+++ b/packages/g6/src/constants/events/container.ts
@@ -1,4 +1,4 @@
-export enum ContainerEvent {
+export const enum ContainerEvent {
/** 按下键盘时触发 | Triggered when the keyboard is pressed */
KEY_DOWN = 'keydown',
/** 抬起键盘时触发 | Triggered when the keyboard is lifted */
diff --git a/packages/g6/src/constants/events/edge.ts b/packages/g6/src/constants/events/edge.ts
index b06bcb8c566..55f5efe7abf 100644
--- a/packages/g6/src/constants/events/edge.ts
+++ b/packages/g6/src/constants/events/edge.ts
@@ -1,4 +1,4 @@
-export enum EdgeEvent {
+export const enum EdgeEvent {
/** 点击时触发 | Triggered when click */
CLICK = 'click',
/** 双击时触发 | Triggered when double click */
diff --git a/packages/g6/src/constants/events/node.ts b/packages/g6/src/constants/events/node.ts
index 9073f974c1d..342df032888 100644
--- a/packages/g6/src/constants/events/node.ts
+++ b/packages/g6/src/constants/events/node.ts
@@ -1,4 +1,4 @@
-export enum NodeEvent {
+export const enum NodeEvent {
/** 点击时触发 | Triggered when click */
CLICK = 'click',
/** 双击时触发 | Triggered when double click */
diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts
index f74f963c6e4..60117413afd 100644
--- a/packages/g6/src/registry/build-in.ts
+++ b/packages/g6/src/registry/build-in.ts
@@ -1,5 +1,5 @@
import { fade, translate } from '../animations';
-import { ZoomCanvas } from '../behaviors';
+import { DragCanvas, ZoomCanvas } from '../behaviors';
import {
Circle,
Cubic,
@@ -46,6 +46,7 @@ export const BUILT_IN_PLUGINS = {
},
behavior: {
'zoom-canvas': ZoomCanvas,
+ 'drag-canvas': DragCanvas,
},
combo: {},
edge: {
diff --git a/packages/g6/src/registry/types.ts b/packages/g6/src/registry/types.ts
index 0783a7ea779..fcde8a621e2 100644
--- a/packages/g6/src/registry/types.ts
+++ b/packages/g6/src/registry/types.ts
@@ -4,10 +4,10 @@ import type { Behavior } from '../behaviors/types';
import type { STDPalette } from '../palettes/types';
import type { Theme } from '../themes/types';
import type { Edge, Node } from '../types';
+import type { Widget } from '../widgets/types';
// TODO 待使用正式类型定义 / To be used formal type definition
declare type Combo = unknown;
-declare type Widget = unknown;
/**
* 插件注册表
diff --git a/packages/g6/src/runtime/behavior.ts b/packages/g6/src/runtime/behavior.ts
index ca845e868bc..4b74c727ada 100644
--- a/packages/g6/src/runtime/behavior.ts
+++ b/packages/g6/src/runtime/behavior.ts
@@ -1,88 +1,58 @@
import type { DisplayObject, FederatedPointerEvent, FederatedWheelEvent } from '@antv/g';
import type { BaseBehavior } from '../behaviors/base-behavior';
import { CanvasEvent, ContainerEvent } from '../constants';
-import { getPlugin } from '../registry';
-import type { BehaviorOptions } from '../spec';
-import type { STDBehaviorOption } from '../spec/behavior';
+import type { BehaviorOptions, CustomBehaviorOption } from '../spec/behavior';
import type { Target } from '../types';
-import { parseBehaviors } from '../utils/behaviors';
-import { arrayDiff } from '../utils/diff';
import { eventTargetOf } from '../utils/event';
+import { ModuleController } from '../utils/module';
import type { RuntimeContext } from './types';
-export class BehaviorController {
- private context: RuntimeContext;
-
- private behaviors: STDBehaviorOption[] = [];
-
- private behaviorMap: Record> = {};
-
+export class BehaviorController extends ModuleController> {
/** 当前事件的目标 | The current event target */
private currentTarget: Target | null = null;
+ public category: 'widget' | 'behavior' = 'behavior';
+
constructor(context: RuntimeContext) {
- this.context = context;
+ super(context);
this.forwardEvents();
- this.setBehaviors(this.context.options?.behaviors || []);
+ this.setBehaviors(this.context.options.behaviors || []);
}
public setBehaviors(behaviors: BehaviorOptions) {
- const newBehaviors = parseBehaviors(behaviors);
- const { enter, update, exit, keep } = arrayDiff(this.behaviors, newBehaviors, (behavior) => behavior.key);
-
- this.createBehaviors(enter);
- this.updateBehaviors([...update, ...keep]);
- this.destroyBehaviors(exit);
-
- this.behaviors = newBehaviors;
- }
-
- private createBehavior(behavior: STDBehaviorOption) {
- const { key, type } = behavior;
- const Ctor = getPlugin('behavior', type);
- if (!Ctor) return;
-
- const instance = new Ctor(this.context, behavior);
- this.behaviorMap[key] = instance;
- }
-
- private createBehaviors(behaviors: STDBehaviorOption[]) {
- behaviors.forEach((behavior) => this.createBehavior(behavior));
- }
-
- private updateBehaviors(behaviors: STDBehaviorOption[]) {
- behaviors.forEach((behavior) => {
- const { key } = behavior;
- const instance = this.behaviorMap[key];
- if (instance) {
- instance.update(behavior);
- }
- });
- }
-
- private destroyBehavior(key: string) {
- const instance = this.behaviorMap[key];
- if (instance) {
- instance.destroy();
- delete this.behaviorMap[key];
- }
- }
-
- private destroyBehaviors(behaviors: STDBehaviorOption[]) {
- behaviors.forEach(({ key }) => this.destroyBehavior(key));
+ this.setModules(behaviors);
}
private forwardEvents() {
const container = this.context.canvas.getContainer();
if (container) {
- Object.values(ContainerEvent).forEach((name) => {
+ [ContainerEvent.KEY_DOWN, ContainerEvent.KEY_UP].forEach((name) => {
container.addEventListener(name, this.forwardContainerEvents.bind(this));
});
}
const canvas = this.context.canvas.document;
if (canvas) {
- Object.values(CanvasEvent).forEach((name) => {
+ [
+ CanvasEvent.CLICK,
+ CanvasEvent.DBLCLICK,
+ CanvasEvent.POINTER_OVER,
+ CanvasEvent.POINTER_LEAVE,
+ CanvasEvent.POINTER_ENTER,
+ CanvasEvent.POINTER_MOVE,
+ CanvasEvent.POINTER_OUT,
+ CanvasEvent.POINTER_DOWN,
+ CanvasEvent.POINTER_UP,
+ CanvasEvent.CONTEXT_MENU,
+ CanvasEvent.DRAG_START,
+ CanvasEvent.DRAG,
+ CanvasEvent.DRAG_END,
+ CanvasEvent.DRAG_ENTER,
+ CanvasEvent.DRAG_OVER,
+ CanvasEvent.DRAG_LEAVE,
+ CanvasEvent.DROP,
+ CanvasEvent.WHEEL,
+ ].forEach((name) => {
canvas.addEventListener(name, this.forwardCanvasEvents.bind(this));
});
}
@@ -138,14 +108,4 @@ export class BehaviorController {
private forwardContainerEvents(event: FocusEvent | KeyboardEvent) {
this.context.graph.emit(event.type, event);
}
-
- public destroy() {
- Object.keys(this.behaviorMap).forEach((key) => this.destroyBehavior(key));
- // @ts-expect-error force delete
- delete this.context;
- // @ts-expect-error force delete
- delete this.behaviors;
- // @ts-expect-error force delete
- delete this.behaviorMap;
- }
}
diff --git a/packages/g6/src/runtime/graph.ts b/packages/g6/src/runtime/graph.ts
index eb2bb7e1353..f5ed99c3fb2 100644
--- a/packages/g6/src/runtime/graph.ts
+++ b/packages/g6/src/runtime/graph.ts
@@ -44,6 +44,7 @@ import { ElementController } from './element';
import { LayoutController } from './layout';
import { RuntimeContext } from './types';
import { ViewportController } from './viewport';
+import { WidgetController } from './widget';
export class Graph extends EventEmitter {
private options: G6Spec;
@@ -163,8 +164,9 @@ export class Graph extends EventEmitter {
return this.options.behaviors || [];
}
- public setWidgets(widgets: CallableValue): void {
+ public setWidgets(widgets: CallableValue): void {
this.options.widgets = isFunction(widgets) ? widgets(this.getWidgets()) : widgets;
+ this.context.widget?.setWidgets(this.options.widgets);
}
public getWidgets(): WidgetOptions {
@@ -309,6 +311,7 @@ export class Graph extends EventEmitter {
private createRuntime() {
this.context.options = this.options;
+ if (!this.context.widget) this.context.widget = new WidgetController(this.context);
if (!this.context.viewport) this.context.viewport = new ViewportController(this.context);
if (!this.context.element) this.context.element = new ElementController(this.context);
if (!this.context.layout) this.context.layout = new LayoutController(this.context);
@@ -366,12 +369,13 @@ export class Graph extends EventEmitter {
}
public destroy(): void {
- const { layout, element, model, canvas, behavior } = this.context;
+ const { layout, element, model, canvas, behavior, widget } = this.context;
layout?.destroy();
element?.destroy();
model.destroy();
canvas?.destroy();
behavior?.destroy();
+ widget?.destroy();
this.options = {};
// @ts-expect-error force delete
delete this.context;
diff --git a/packages/g6/src/runtime/types.ts b/packages/g6/src/runtime/types.ts
index c2bb2ba437a..74858e48ee6 100644
--- a/packages/g6/src/runtime/types.ts
+++ b/packages/g6/src/runtime/types.ts
@@ -6,6 +6,7 @@ import type { ElementController } from './element';
import type { Graph } from './graph';
import type { LayoutController } from './layout';
import type { ViewportController } from './viewport';
+import type { WidgetController } from './widget';
export interface RuntimeContext {
/**
@@ -60,4 +61,10 @@ export interface RuntimeContext {
* Behavior controller
*/
behavior?: BehaviorController;
+ /**
+ * 组件控制器
+ *
+ * Widget controller
+ */
+ widget?: WidgetController;
}
diff --git a/packages/g6/src/runtime/widget.ts b/packages/g6/src/runtime/widget.ts
new file mode 100644
index 00000000000..37f580c996d
--- /dev/null
+++ b/packages/g6/src/runtime/widget.ts
@@ -0,0 +1,17 @@
+import type { CustomWidgetOption, WidgetOptions } from '../spec/widget';
+import { ModuleController } from '../utils/module';
+import type { BaseWidget } from '../widgets/base-widget';
+import type { RuntimeContext } from './types';
+
+export class WidgetController extends ModuleController> {
+ public category: 'widget' | 'behavior' = 'widget';
+
+ constructor(context: RuntimeContext) {
+ super(context);
+ this.setWidgets(this.context.options.widgets || []);
+ }
+
+ public setWidgets(widgets: WidgetOptions) {
+ this.setModules(widgets);
+ }
+}
diff --git a/packages/g6/src/spec/behavior.ts b/packages/g6/src/spec/behavior.ts
index a5e9c3d731b..e91ee5dbef8 100644
--- a/packages/g6/src/spec/behavior.ts
+++ b/packages/g6/src/spec/behavior.ts
@@ -1,9 +1,8 @@
import type { BuiltInBehaviorOptions } from '../behaviors';
+import type { LooselyModuleOption, ModuleOptions, STDModuleOption } from '../types';
-export type BehaviorOptions = Abbr[];
+export type BehaviorOptions = ModuleOptions;
-export type STDBehaviorOption = { type: string; key: string; [key: string]: unknown };
+export type STDBehaviorOption = STDModuleOption;
-export type CustomBehaviorOption = { type: string; key?: string; [key: string]: unknown };
-
-type Abbr = (R & { key?: string }) | R['type'];
+export type CustomBehaviorOption = LooselyModuleOption;
diff --git a/packages/g6/src/spec/widget.ts b/packages/g6/src/spec/widget.ts
index b3f033d61ad..b8706770025 100644
--- a/packages/g6/src/spec/widget.ts
+++ b/packages/g6/src/spec/widget.ts
@@ -1,12 +1,8 @@
+import type { LooselyModuleOption, ModuleOptions, STDModuleOption } from '../types';
import type { BuiltInWidgetOptions } from '../widgets/types';
-export type WidgetOptions = Abbr[];
+export type WidgetOptions = ModuleOptions;
-type CustomWidgetOptions = STDWidgetOptions;
+export type STDWidgetOption = STDModuleOption;
-export interface STDWidgetOptions {
- type: string;
- [key: string]: unknown;
-}
-
-type Abbr = (R & { key?: string }) | R['type'];
+export type CustomWidgetOption = LooselyModuleOption;
diff --git a/packages/g6/src/types/behavior.ts b/packages/g6/src/types/behavior.ts
index a8b15f54c11..0a1394ff052 100644
--- a/packages/g6/src/types/behavior.ts
+++ b/packages/g6/src/types/behavior.ts
@@ -1,3 +1,5 @@
-export type BehaviorEvent = T & {
+import type { FederatedEvent } from '@antv/g';
+
+export type BehaviorEvent = T & {
targetType: 'canvas' | 'node' | 'edge' | 'combo';
};
diff --git a/packages/g6/src/types/change.ts b/packages/g6/src/types/change.ts
index 0f167adbd34..9c241f1ff19 100644
--- a/packages/g6/src/types/change.ts
+++ b/packages/g6/src/types/change.ts
@@ -1,6 +1,6 @@
import { ChangeTypeEnum } from '../constants';
import type { ComboData, EdgeData, NodeData } from '../spec/data';
-import { Loose } from './enum';
+import { Loosen } from './enum';
/**
* 数据变更
@@ -16,49 +16,49 @@ export type DataUpdated = NodeUpdated | EdgeUpdated | ComboUpdated;
export type DataRemoved = NodeRemoved | EdgeRemoved | ComboRemoved;
export type NodeAdded = {
- type: Loose;
+ type: Loosen;
value: NodeData;
};
export type NodeUpdated = {
- type: Loose;
+ type: Loosen;
value: NodeData;
original: NodeData;
};
export type NodeRemoved = {
- type: Loose;
+ type: Loosen;
value: NodeData;
};
export type EdgeAdded = {
- type: Loose;
+ type: Loosen;
value: EdgeData;
};
export type EdgeUpdated = {
- type: Loose;
+ type: Loosen;
value: EdgeData;
original: EdgeData;
};
export type EdgeRemoved = {
- type: Loose;
+ type: Loosen;
value: EdgeData;
};
export type ComboAdded = {
- type: Loose;
+ type: Loosen;
value: ComboData;
};
export type ComboUpdated = {
- type: Loose;
+ type: Loosen;
value: ComboData;
original: ComboData;
};
export type ComboRemoved = {
- type: Loose;
+ type: Loosen;
value: ComboData;
};
diff --git a/packages/g6/src/types/enum.ts b/packages/g6/src/types/enum.ts
index 24e5ba776f1..b00b35b49fd 100644
--- a/packages/g6/src/types/enum.ts
+++ b/packages/g6/src/types/enum.ts
@@ -1 +1 @@
-export type Loose = `${T}`;
+export type Loosen = `${T}`;
diff --git a/packages/g6/src/types/index.ts b/packages/g6/src/types/index.ts
index c79fd944f67..0594628baa4 100644
--- a/packages/g6/src/types/index.ts
+++ b/packages/g6/src/types/index.ts
@@ -10,6 +10,7 @@ export type * from './enum';
export type * from './event';
export type * from './graphlib';
export type * from './layout';
+export type * from './module';
export type * from './node';
export type * from './padding';
export type * from './point';
diff --git a/packages/g6/src/types/module.ts b/packages/g6/src/types/module.ts
new file mode 100644
index 00000000000..a5835e6dfe9
--- /dev/null
+++ b/packages/g6/src/types/module.ts
@@ -0,0 +1,44 @@
+/**
+ * 模块配置项
+ *
+ * Module options
+ */
+export type ModuleOptions = Record> = ModuleOption<
+ Registry | LooselyModuleOption
+>[];
+
+/**
+ * 模块配置项
+ *
+ * Module option
+ */
+type ModuleOption = Record> = AbbrModuleOption<
+ LooselyModuleOption
+>;
+
+/**
+ * 标准模块配置项
+ *
+ * Standard module options
+ */
+export type STDModuleOption = Record> = {
+ type: string;
+ key: string;
+} & Registry;
+
+/**
+ * 宽松的模块配置项,可以不传入 key
+ *
+ * Loosely module option, key can be omitted
+ */
+export type LooselyModuleOption = Record> = {
+ type: string;
+ key?: string;
+} & Registry;
+
+/**
+ * 模块配置项简写,支持直接传入 type 字符串
+ *
+ * Module option abbreviation, support directly passing in type string
+ */
+type AbbrModuleOption = (S & { key?: string }) | S['type'];
diff --git a/packages/g6/src/utils/behaviors.ts b/packages/g6/src/utils/behaviors.ts
deleted file mode 100644
index 62647f4ba4e..00000000000
--- a/packages/g6/src/utils/behaviors.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { BehaviorOptions } from '../spec';
-import type { STDBehaviorOption } from '../spec/behavior';
-
-/**
- * 将行为配置项转换为标准格式
- *
- * Convert behavior options to standard format
- * @param behaviors - 行为配置项 behavior options
- * @returns 标准行为配置项 Standard behavior options
- */
-export function parseBehaviors(behaviors: BehaviorOptions): STDBehaviorOption[] {
- const counter: Record = {};
- const getKey = (type: string) => {
- if (!(type in counter)) counter[type] = 0;
- return `behavior-${type}-${counter[type]++}`;
- };
- return behaviors.map((behavior) => {
- if (typeof behavior === 'string') {
- return { type: behavior, key: getKey(behavior) };
- }
- if (behavior.key) return behavior as STDBehaviorOption;
- return { ...behavior, key: getKey(behavior.type) };
- });
-}
diff --git a/packages/g6/src/utils/module.ts b/packages/g6/src/utils/module.ts
new file mode 100644
index 00000000000..597a2494985
--- /dev/null
+++ b/packages/g6/src/utils/module.ts
@@ -0,0 +1,147 @@
+/**
+ * @file module.ts
+ * @description
+ * Behavior 和 Widget 作为可插拔的模块,具有较高的相似性,因此将其抽象为`模块`
+ *
+ * Behavior and Widget are pluggable modules with high similarity, so they are abstracted as `module`
+ */
+import type EventEmitter from '@antv/event-emitter';
+import { getPlugin } from '../registry';
+import type { RuntimeContext } from '../runtime/types';
+import type { Listener, LooselyModuleOption, ModuleOptions, STDModuleOption } from '../types';
+import { arrayDiff } from './diff';
+
+export abstract class ModuleController> {
+ protected context: RuntimeContext;
+
+ protected modules: STDModuleOption[] = [];
+
+ protected moduleMap: Record = {};
+
+ public abstract category: 'widget' | 'behavior';
+
+ constructor(context: RuntimeContext) {
+ this.context = context;
+ }
+
+ public setModules(modules: ModuleOptions) {
+ const stdModules = parseModules(this.category, modules) as STDModuleOption[];
+ const { enter, update, exit, keep } = arrayDiff(this.modules, stdModules, (module) => module.key);
+
+ this.createModules(enter);
+ this.updateModules([...update, ...keep]);
+ this.destroyModules(exit);
+
+ this.modules = stdModules;
+ }
+
+ protected createModule(module: STDModuleOption) {
+ const { category } = this;
+
+ const { key, type } = module;
+ const Ctor = getPlugin(category as any, type);
+ if (!Ctor) return;
+
+ const instance = new Ctor(this.context, module);
+ this.moduleMap[key] = instance;
+ }
+
+ protected createModules(modules: STDModuleOption[]) {
+ modules.forEach((module) => this.createModule(module));
+ }
+
+ protected updateModule(module: STDModuleOption) {
+ const { key } = module;
+ const instance = this.moduleMap[key];
+ if (instance) {
+ instance.update(module);
+ }
+ }
+
+ protected updateModules(modules: STDModuleOption[]) {
+ modules.forEach((module) => this.updateModule(module));
+ }
+
+ protected destroyModule(key: string) {
+ const instance = this.moduleMap[key];
+ if (instance) {
+ instance.destroy();
+ delete this.moduleMap[key];
+ }
+ }
+
+ protected destroyModules(modules: STDModuleOption[]) {
+ modules.forEach(({ key }) => this.destroyModule(key));
+ }
+
+ public destroy() {
+ Object.values(this.moduleMap).forEach((module) => module.destroy());
+ // @ts-expect-error force delete
+ delete this.context;
+ // @ts-expect-error force delete
+ delete this.modules;
+ // @ts-expect-error force delete
+ delete this.moduleMap;
+ }
+}
+
+/**
+ * 模块实例基类
+ *
+ * Base class for module instance
+ */
+export class BaseModule {
+ protected context: RuntimeContext;
+
+ protected options: Required;
+
+ protected events: [EventEmitter | HTMLElement, string, Listener][] = [];
+
+ public destroyed = false;
+
+ constructor(context: RuntimeContext, options: T) {
+ this.context = context;
+ this.options = options as Required;
+ }
+
+ update(options: Partial) {
+ this.options = Object.assign(this.options, options);
+ }
+
+ public destroy() {
+ // @ts-expect-error force delete
+ delete this.context;
+ // @ts-expect-error force delete
+ delete this.options;
+
+ this.destroyed = true;
+ }
+}
+
+/**
+ * 将模块配置项转换为标准模块格式
+ *
+ * Convert module options to standard format
+ * @param category - 模块类型 module type
+ * @param modules - 模块配置项 module options
+ * @returns 标准模块配置项 Standard module options
+ */
+export function parseModules>(
+ category: 'widget' | 'behavior',
+ modules: ModuleOptions,
+): STDModuleOption[] {
+ const counter: Record = {};
+
+ const getKey = (type: string) => {
+ if (!(type in counter)) counter[type] = 0;
+ return `${category}-${type}-${counter[type]++}`;
+ };
+
+ return modules.map((module) => {
+ if (typeof module === 'string') {
+ return { type: module, key: getKey(module) };
+ }
+ if (module.key) return module;
+ return { ...module, key: getKey(module.type) };
+ }) as STDModuleOption[];
+}
diff --git a/packages/g6/src/utils/shortcut.ts b/packages/g6/src/utils/shortcut.ts
new file mode 100644
index 00000000000..82fc9b289e6
--- /dev/null
+++ b/packages/g6/src/utils/shortcut.ts
@@ -0,0 +1,77 @@
+import EventEmitter from '@antv/event-emitter';
+import { isEqual } from '@antv/util';
+import { CommonEvent } from '../constants';
+
+export interface ShortcutOptions {}
+
+export type ShortcutKey = string[];
+
+type Handler = (event: any) => void;
+
+export class Shortcut {
+ private map: Map = new Map();
+
+ private emitter: EventEmitter;
+
+ private recordKey = new Set();
+
+ constructor(emitter: EventEmitter) {
+ this.emitter = emitter;
+ this.bindEvents();
+ }
+
+ public bind(key: ShortcutKey, handler: Handler) {
+ if (key.length === 0) return;
+ this.map.set(key, handler);
+ }
+
+ public unbind(key: ShortcutKey, handler?: Handler) {
+ this.map.forEach((h, k) => {
+ if (isEqual(k, key)) {
+ if (!handler || handler === h) this.map.delete(k);
+ }
+ });
+ }
+
+ public unbindAll() {
+ this.map.clear();
+ }
+
+ private bindEvents() {
+ const { emitter } = this;
+
+ emitter.on(CommonEvent.KEY_DOWN, this.onKeyDown);
+ emitter.on(CommonEvent.KEY_UP, this.onKeyUp);
+ emitter.on(CommonEvent.WHEEL, this.onWheel);
+ }
+
+ private onKeyDown = (event: KeyboardEvent) => {
+ this.recordKey.add(event.key);
+ this.trigger(event);
+ };
+
+ private onKeyUp = (event: KeyboardEvent) => {
+ this.recordKey.delete(event.key);
+ };
+
+ private trigger(event: KeyboardEvent) {
+ this.map.forEach((handler, key) => {
+ if (isEqual(Array.from(this.recordKey), key)) handler(event);
+ });
+ }
+
+ private onWheel = (event: WheelEvent) => {
+ this.map.forEach((handler, key) => {
+ if (key.includes(CommonEvent.WHEEL)) {
+ if (
+ isEqual(
+ Array.from(this.recordKey),
+ key.filter((k) => k !== CommonEvent.WHEEL),
+ )
+ ) {
+ handler(event);
+ }
+ }
+ });
+ };
+}
diff --git a/packages/g6/src/widgets/base-widget.ts b/packages/g6/src/widgets/base-widget.ts
new file mode 100644
index 00000000000..401d2c058be
--- /dev/null
+++ b/packages/g6/src/widgets/base-widget.ts
@@ -0,0 +1,6 @@
+import type { CustomWidgetOption } from '../spec/widget';
+import { BaseModule } from '../utils/module';
+
+export type BaseWidgetOptions = CustomWidgetOption;
+
+export abstract class BaseWidget extends BaseModule {}
diff --git a/packages/g6/src/widgets/types.ts b/packages/g6/src/widgets/types.ts
index affa09ba405..eaac1832569 100644
--- a/packages/g6/src/widgets/types.ts
+++ b/packages/g6/src/widgets/types.ts
@@ -1 +1,4 @@
+import type { BaseWidget } from './base-widget';
+
export type BuiltInWidgetOptions = { type: 'unset' };
+export type Widget = BaseWidget;