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;