feat: add drag canvas behavior (#5475)

* refactor(types): rename loose to loosen

* feat(utils): add Shortcut util

* refactor(behaviors): refactor zoom canvas with shortcuts

* chore(test): update toBeCloseTo message style

* feat(behaviors): add drag-canvas behavior

* refactor(utils): abstract out module from behavior and widget

* feat(runtime): add widget controller

* refactor: fix cr issues
This commit is contained in:
Aaron 2024-02-28 15:45:25 +08:00 committed by GitHub
parent e802349d81
commit b98a164d5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 3304 additions and 287 deletions

View File

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

View File

@ -1 +1,2 @@
export * from './behavior-drag-canvas';
export * from './behavior-zoom-canvas'; export * from './behavior-zoom-canvas';

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -33,16 +33,9 @@ declare global {
expect.extend({ expect.extend({
toBeCloseTo: (received: Digital, expected: Digital, numDigits?: number) => { toBeCloseTo: (received: Digital, expected: Digital, numDigits?: number) => {
const pass = toBeCloseTo(received, expected, numDigits); const pass = toBeCloseTo(received, expected, numDigits);
if (pass) { return {
return { message: () => `expected: \x1b[32m${received}\n\x1b[31mreceived: ${expected}\x1b[0m`,
message: () => `expected ${received} not to be close to ${expected}`, pass,
pass: true, };
};
} else {
return {
message: () => `expected ${received} to be close to ${expected}`,
pass: false,
};
}
}, },
}); });

View File

@ -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 { CustomBehaviorOption } from '../spec/behavior';
import type { Listener } from '../types'; import { BaseModule } from '../utils/module';
export type BaseBehaviorOptions = CustomBehaviorOption; export type BaseBehaviorOptions = CustomBehaviorOption;
export abstract class BaseBehavior<T extends BaseBehaviorOptions> { export abstract class BaseBehavior<T extends BaseBehaviorOptions> extends BaseModule<T> {}
protected context: RuntimeContext;
protected options: Required<T>;
public events: [EventEmitter | HTMLElement, string, Listener][] = [];
public destroyed = false;
public get defaultOptions(): Partial<T> {
return {};
}
constructor(context: RuntimeContext, options: T) {
this.context = context;
this.options = Object.assign({}, this.defaultOptions, options) as Required<T>;
}
public update(options: Partial<T>) {
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;
}
}

View File

@ -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 {
/**
* <zh/> 使
*
* <en/> Whether to enable the animation of zooming, only valid when using key movement
*/
animation?: ViewportAnimationEffectTiming;
/**
* <zh/>
*
* <en/> Whether to enable the function of dragging the canvas
*/
enable?: boolean | ((event: BehaviorEvent<FederatedMouseEvent> | BehaviorEvent<KeyboardEvent>) => boolean);
/**
* <zh/> 使
*
* <en/> The way to trigger dragging, default to dragging with the pointer pressed
*/
trigger?: CombinationKey;
/**
* <zh/>
*
* <en/> The distance of a single key movement
*/
sensitivity?: number;
/**
* <zh/>
*
* <en/> Callback when dragging is completed
*/
onfinish?: () => void;
}
type CombinationKey = {
up: ShortcutKey;
down: ShortcutKey;
left: ShortcutKey;
right: ShortcutKey;
};
export class DragCanvas extends BaseBehavior<DragCanvasOptions> {
static defaultOptions: Partial<DragCanvasOptions> = {
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<FederatedMouseEvent>) => {
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<FederatedMouseEvent> | BehaviorEvent<KeyboardEvent>) {
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<FederatedMouseEvent> | BehaviorEvent<KeyboardEvent>) {
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();
}
}

View File

@ -1,7 +1,9 @@
import type { ZoomCanvasOptions } from './zoom-canvas'; import type { ZoomCanvasOptions } from './zoom-canvas';
export { BaseBehavior } from './base-behavior'; export { BaseBehavior } from './base-behavior';
export { DragCanvas } from './drag-canvas';
export { ZoomCanvas } from './zoom-canvas'; export { ZoomCanvas } from './zoom-canvas';
export type { BaseBehaviorOptions } from './base-behavior'; export type { BaseBehaviorOptions } from './base-behavior';
export type { DragCanvasOptions } from './drag-canvas';
export type BuiltInBehaviorOptions = ZoomCanvasOptions; export type BuiltInBehaviorOptions = ZoomCanvasOptions;

View File

@ -1,4 +1,6 @@
import type { BaseBehavior } from './base-behavior'; 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<any>; export type Behavior = BaseBehavior<any>;

View File

@ -1,7 +1,9 @@
import { isArray, isEqual, isObject } from '@antv/util'; import { isArray, isFunction, isObject } from '@antv/util';
import { CommonEvent } from '../constants'; import { CanvasEvent } from '../constants';
import type { RuntimeContext } from '../runtime/types'; 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 type { BaseBehaviorOptions } from './base-behavior';
import { BaseBehavior } from './base-behavior'; import { BaseBehavior } from './base-behavior';
@ -17,23 +19,21 @@ export interface ZoomCanvasOptions extends BaseBehaviorOptions {
* *
* <en/> Whether to enable the function of zooming the canvas * <en/> Whether to enable the function of zooming the canvas
*/ */
enable?: boolean | ((event: BehaviorEvent) => boolean); enable?: boolean | ((event: BehaviorEvent<WheelEvent> | BehaviorEvent<KeyboardEvent>) => boolean);
/** /**
* <zh/> * <zh/>
* *
* <en/> The way to trigger zoom * <en/> The way to trigger zoom
* @description * @description
* <zh/> * <zh/>
* - 'wheel' * - 使['Control'] Control
* - ['ctrl'] ctrl * - { zoomIn: ['Control', '+'], zoomOut: ['Control', '-'], reset: ['Control', '0'] }
* - { zoomIn: ['ctrl', '+'], zoomOut: ['ctrl', '-'], reset: ['ctrl', '0'] }
* *
* <en/> * <en/>
* - 'wheel': Trigger zoom when scrolling the mouse wheel or touchpad * - 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
* - Array: Combination shortcut keys, such as ['ctrl'] means zooming when scrolling the mouse wheel while holding down the ctrl key * - Object: Zoom shortcut key, such as { zoomIn: ['Control', '+'], zoomOut: ['Control', '-'], reset: ['Control', '0'] }
* - Object: Zoom shortcut keys, such as { zoomIn: ['ctrl', '+'], zoomOut: ['ctrl', '-'], reset: ['ctrl', '0'] }
*/ */
trigger?: Loose<CommonEvent.WHEEL> | string[] | CombinationKey; trigger?: ShortcutKey | CombinationKey;
/** /**
* <zh/> * <zh/>
* *
@ -49,86 +49,62 @@ export interface ZoomCanvasOptions extends BaseBehaviorOptions {
} }
type CombinationKey = { type CombinationKey = {
zoomIn: string[]; zoomIn: ShortcutKey;
zoomOut: string[]; zoomOut: ShortcutKey;
reset: string[]; reset: ShortcutKey;
}; };
export class ZoomCanvas extends BaseBehavior<ZoomCanvasOptions> { export class ZoomCanvas extends BaseBehavior<ZoomCanvasOptions> {
private preconditionKey?: string[]; static defaultOptions: Partial<ZoomCanvasOptions> = {
animation: { duration: 200 },
private recordKey = new Set<string>(); enable: true,
sensitivity: 1,
private combinationKey: CombinationKey = { trigger: [],
zoomIn: [],
zoomOut: [],
reset: [],
}; };
private shortcut: Shortcut;
private get animation() { private get animation() {
return this.context.options.animation ? this.options.animation : false; return this.context.options.animation ? this.options.animation : false;
} }
public get defaultOptions(): Partial<ZoomCanvasOptions> {
return { animation: { duration: 200 }, enable: true, sensitivity: 1, trigger: CommonEvent.WHEEL };
}
constructor(context: RuntimeContext, options: ZoomCanvasOptions) { constructor(context: RuntimeContext, options: ZoomCanvasOptions) {
super(context, options); super(context, Object.assign({}, ZoomCanvas.defaultOptions, options));
if (isArray(this.options.trigger)) { this.shortcut = new Shortcut(context.graph);
this.preconditionKey = this.options.trigger;
}
if (isObject(this.options.trigger)) {
this.combinationKey = this.options.trigger as CombinationKey;
}
this.bindEvents(); this.bindEvents();
} }
private bindEvents() { public update(options: Partial<ZoomCanvasOptions>): void {
const { graph } = this.context; super.update(options);
this.bindEvents();
}
// wheel 触发和组合键触发需要监听 wheel 事件 / Combination key trigger and wheel trigger need to listen to the wheel event private bindEvents() {
if (this.options.trigger === CommonEvent.WHEEL || isArray(this.options.trigger)) { const { trigger } = this.options;
this.preventDefault(CommonEvent.WHEEL); this.shortcut.unbindAll();
this.addEventListener(graph, CommonEvent.WHEEL, this.onWheel.bind(this));
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)) { if (isObject(trigger)) {
this.addEventListener(graph, CommonEvent.KEY_DOWN, this.onKeydown.bind(this)); const { zoomIn = [], zoomOut = [], reset = [] } = trigger as CombinationKey;
this.addEventListener(graph, CommonEvent.KEY_UP, this.onKeyup.bind(this)); 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<WheelEvent>) { private onWheel = (event: BehaviorEvent<WheelEvent>) => {
const { deltaX, deltaY } = event; const { deltaX, deltaY } = event;
const delta = -(deltaY || deltaX); const delta = -(deltaY || deltaX);
this.zoom(delta, event); this.zoom(delta, event);
} };
private onKeydown(event: BehaviorEvent<KeyboardEvent>) {
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);
}
/** /**
* <zh/> * <zh/>
@ -137,7 +113,7 @@ export class ZoomCanvas extends BaseBehavior<ZoomCanvasOptions> {
* @param value - <zh/> > 0 < 0 | <en/> Zoom value, > 0 zoom in, < 0 zoom out * @param value - <zh/> > 0 < 0 | <en/> Zoom value, > 0 zoom in, < 0 zoom out
* @param event - <zh/> | <en/> Event object * @param event - <zh/> | <en/> Event object
*/ */
private async zoom(value: number, event: BehaviorEvent<WheelEvent> | BehaviorEvent<KeyboardEvent>) { private zoom = async (value: number, event: BehaviorEvent<WheelEvent> | BehaviorEvent<KeyboardEvent>) => {
if (!this.validate(event)) return; if (!this.validate(event)) return;
const { viewport } = this.context; const { viewport } = this.context;
if (!viewport) return; if (!viewport) return;
@ -148,26 +124,24 @@ export class ZoomCanvas extends BaseBehavior<ZoomCanvasOptions> {
await viewport.zoom({ mode: 'absolute', value: zoom + diff }, this.animation); await viewport.zoom({ mode: 'absolute', value: zoom + diff }, this.animation);
onfinish?.(); onfinish?.();
} };
private async reset() { private onReset = async () => {
const { viewport } = this.context; const { viewport } = this.context;
await viewport?.zoom({ mode: 'absolute', value: 1 }, this.animation); await viewport?.zoom({ mode: 'absolute', value: 1 }, this.animation);
} };
private validate(event: BehaviorEvent<WheelEvent> | BehaviorEvent<KeyboardEvent>) { private validate(event: BehaviorEvent<WheelEvent> | BehaviorEvent<KeyboardEvent>) {
if (this.preconditionKey && !isEqual(this.preconditionKey, Array.from(this.recordKey))) return false; if (this.destroyed) return false;
const { enable } = this.options; const { enable } = this.options;
if (typeof enable === 'function' && !enable(event)) return false; if (isFunction(enable)) return enable(event);
if (enable === false) return false; return !!enable;
return true;
} }
private preventDefault(eventName: string) { private preventDefault(eventName: string) {
const listener = (e: Event) => e.preventDefault(); const listener = (e: Event) => e.preventDefault();
const container = this.context.canvas.getContainer(); const container = this.context.canvas.getContainer();
if (!container) return; if (!container) return;
this.addEventListener(container, eventName, listener); container.addEventListener(eventName, listener);
} }
} }

View File

@ -1,4 +1,4 @@
export enum CanvasEvent { export const enum CanvasEvent {
/** <zh/> 点击时触发 | <en/> Triggered when click */ /** <zh/> 点击时触发 | <en/> Triggered when click */
CLICK = 'click', CLICK = 'click',
/** <zh/> 双击时触发 | <en/> Triggered when double click */ /** <zh/> 双击时触发 | <en/> Triggered when double click */

View File

@ -1,4 +1,4 @@
export enum CommonEvent { export const enum CommonEvent {
/** <zh/> 点击时触发 | <en/> Triggered when click */ /** <zh/> 点击时触发 | <en/> Triggered when click */
CLICK = 'click', CLICK = 'click',
/** <zh/> 双击时触发 | <en/> Triggered when double click */ /** <zh/> 双击时触发 | <en/> Triggered when double click */

View File

@ -1,4 +1,4 @@
export enum ContainerEvent { export const enum ContainerEvent {
/** <zh/> 按下键盘时触发 | <en/> Triggered when the keyboard is pressed */ /** <zh/> 按下键盘时触发 | <en/> Triggered when the keyboard is pressed */
KEY_DOWN = 'keydown', KEY_DOWN = 'keydown',
/** <zh/> 抬起键盘时触发 | <en/> Triggered when the keyboard is lifted */ /** <zh/> 抬起键盘时触发 | <en/> Triggered when the keyboard is lifted */

View File

@ -1,4 +1,4 @@
export enum EdgeEvent { export const enum EdgeEvent {
/** <zh/> 点击时触发 | <en/> Triggered when click */ /** <zh/> 点击时触发 | <en/> Triggered when click */
CLICK = 'click', CLICK = 'click',
/** <zh/> 双击时触发 | <en/> Triggered when double click */ /** <zh/> 双击时触发 | <en/> Triggered when double click */

View File

@ -1,4 +1,4 @@
export enum NodeEvent { export const enum NodeEvent {
/** <zh/> 点击时触发 | <en/> Triggered when click */ /** <zh/> 点击时触发 | <en/> Triggered when click */
CLICK = 'click', CLICK = 'click',
/** <zh/> 双击时触发 | <en/> Triggered when double click */ /** <zh/> 双击时触发 | <en/> Triggered when double click */

View File

@ -1,5 +1,5 @@
import { fade, translate } from '../animations'; import { fade, translate } from '../animations';
import { ZoomCanvas } from '../behaviors'; import { DragCanvas, ZoomCanvas } from '../behaviors';
import { import {
Circle, Circle,
Cubic, Cubic,
@ -46,6 +46,7 @@ export const BUILT_IN_PLUGINS = {
}, },
behavior: { behavior: {
'zoom-canvas': ZoomCanvas, 'zoom-canvas': ZoomCanvas,
'drag-canvas': DragCanvas,
}, },
combo: {}, combo: {},
edge: { edge: {

View File

@ -4,10 +4,10 @@ import type { Behavior } from '../behaviors/types';
import type { STDPalette } from '../palettes/types'; import type { STDPalette } from '../palettes/types';
import type { Theme } from '../themes/types'; import type { Theme } from '../themes/types';
import type { Edge, Node } from '../types'; import type { Edge, Node } from '../types';
import type { Widget } from '../widgets/types';
// TODO 待使用正式类型定义 / To be used formal type definition // TODO 待使用正式类型定义 / To be used formal type definition
declare type Combo = unknown; declare type Combo = unknown;
declare type Widget = unknown;
/** /**
* <zh/> * <zh/>

View File

@ -1,88 +1,58 @@
import type { DisplayObject, FederatedPointerEvent, FederatedWheelEvent } from '@antv/g'; import type { DisplayObject, FederatedPointerEvent, FederatedWheelEvent } from '@antv/g';
import type { BaseBehavior } from '../behaviors/base-behavior'; import type { BaseBehavior } from '../behaviors/base-behavior';
import { CanvasEvent, ContainerEvent } from '../constants'; import { CanvasEvent, ContainerEvent } from '../constants';
import { getPlugin } from '../registry'; import type { BehaviorOptions, CustomBehaviorOption } from '../spec/behavior';
import type { BehaviorOptions } from '../spec';
import type { STDBehaviorOption } from '../spec/behavior';
import type { Target } from '../types'; import type { Target } from '../types';
import { parseBehaviors } from '../utils/behaviors';
import { arrayDiff } from '../utils/diff';
import { eventTargetOf } from '../utils/event'; import { eventTargetOf } from '../utils/event';
import { ModuleController } from '../utils/module';
import type { RuntimeContext } from './types'; import type { RuntimeContext } from './types';
export class BehaviorController { export class BehaviorController extends ModuleController<BaseBehavior<CustomBehaviorOption>> {
private context: RuntimeContext;
private behaviors: STDBehaviorOption[] = [];
private behaviorMap: Record<string, BaseBehavior<any>> = {};
/** <zh/> 当前事件的目标 | <en/> The current event target */ /** <zh/> 当前事件的目标 | <en/> The current event target */
private currentTarget: Target | null = null; private currentTarget: Target | null = null;
public category: 'widget' | 'behavior' = 'behavior';
constructor(context: RuntimeContext) { constructor(context: RuntimeContext) {
this.context = context; super(context);
this.forwardEvents(); this.forwardEvents();
this.setBehaviors(this.context.options?.behaviors || []); this.setBehaviors(this.context.options.behaviors || []);
} }
public setBehaviors(behaviors: BehaviorOptions) { public setBehaviors(behaviors: BehaviorOptions) {
const newBehaviors = parseBehaviors(behaviors); this.setModules(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));
} }
private forwardEvents() { private forwardEvents() {
const container = this.context.canvas.getContainer(); const container = this.context.canvas.getContainer();
if (container) { if (container) {
Object.values(ContainerEvent).forEach((name) => { [ContainerEvent.KEY_DOWN, ContainerEvent.KEY_UP].forEach((name) => {
container.addEventListener(name, this.forwardContainerEvents.bind(this)); container.addEventListener(name, this.forwardContainerEvents.bind(this));
}); });
} }
const canvas = this.context.canvas.document; const canvas = this.context.canvas.document;
if (canvas) { 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)); canvas.addEventListener(name, this.forwardCanvasEvents.bind(this));
}); });
} }
@ -138,14 +108,4 @@ export class BehaviorController {
private forwardContainerEvents(event: FocusEvent | KeyboardEvent) { private forwardContainerEvents(event: FocusEvent | KeyboardEvent) {
this.context.graph.emit(event.type, event); 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;
}
} }

View File

@ -44,6 +44,7 @@ import { ElementController } from './element';
import { LayoutController } from './layout'; import { LayoutController } from './layout';
import { RuntimeContext } from './types'; import { RuntimeContext } from './types';
import { ViewportController } from './viewport'; import { ViewportController } from './viewport';
import { WidgetController } from './widget';
export class Graph extends EventEmitter { export class Graph extends EventEmitter {
private options: G6Spec; private options: G6Spec;
@ -163,8 +164,9 @@ export class Graph extends EventEmitter {
return this.options.behaviors || []; return this.options.behaviors || [];
} }
public setWidgets(widgets: CallableValue<G6Spec['widgets']>): void { public setWidgets(widgets: CallableValue<WidgetOptions>): void {
this.options.widgets = isFunction(widgets) ? widgets(this.getWidgets()) : widgets; this.options.widgets = isFunction(widgets) ? widgets(this.getWidgets()) : widgets;
this.context.widget?.setWidgets(this.options.widgets);
} }
public getWidgets(): WidgetOptions { public getWidgets(): WidgetOptions {
@ -309,6 +311,7 @@ export class Graph extends EventEmitter {
private createRuntime() { private createRuntime() {
this.context.options = this.options; 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.viewport) this.context.viewport = new ViewportController(this.context);
if (!this.context.element) this.context.element = new ElementController(this.context); if (!this.context.element) this.context.element = new ElementController(this.context);
if (!this.context.layout) this.context.layout = new LayoutController(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 { public destroy(): void {
const { layout, element, model, canvas, behavior } = this.context; const { layout, element, model, canvas, behavior, widget } = this.context;
layout?.destroy(); layout?.destroy();
element?.destroy(); element?.destroy();
model.destroy(); model.destroy();
canvas?.destroy(); canvas?.destroy();
behavior?.destroy(); behavior?.destroy();
widget?.destroy();
this.options = {}; this.options = {};
// @ts-expect-error force delete // @ts-expect-error force delete
delete this.context; delete this.context;

View File

@ -6,6 +6,7 @@ import type { ElementController } from './element';
import type { Graph } from './graph'; import type { Graph } from './graph';
import type { LayoutController } from './layout'; import type { LayoutController } from './layout';
import type { ViewportController } from './viewport'; import type { ViewportController } from './viewport';
import type { WidgetController } from './widget';
export interface RuntimeContext { export interface RuntimeContext {
/** /**
@ -60,4 +61,10 @@ export interface RuntimeContext {
* <en/> Behavior controller * <en/> Behavior controller
*/ */
behavior?: BehaviorController; behavior?: BehaviorController;
/**
* <zh/>
*
* <en/> Widget controller
*/
widget?: WidgetController;
} }

View File

@ -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<BaseWidget<CustomWidgetOption>> {
public category: 'widget' | 'behavior' = 'widget';
constructor(context: RuntimeContext) {
super(context);
this.setWidgets(this.context.options.widgets || []);
}
public setWidgets(widgets: WidgetOptions) {
this.setModules(widgets);
}
}

View File

@ -1,9 +1,8 @@
import type { BuiltInBehaviorOptions } from '../behaviors'; import type { BuiltInBehaviorOptions } from '../behaviors';
import type { LooselyModuleOption, ModuleOptions, STDModuleOption } from '../types';
export type BehaviorOptions = Abbr<BuiltInBehaviorOptions | CustomBehaviorOption>[]; export type BehaviorOptions = ModuleOptions<BuiltInBehaviorOptions>;
export type STDBehaviorOption = { type: string; key: string; [key: string]: unknown }; export type STDBehaviorOption = STDModuleOption<BuiltInBehaviorOptions>;
export type CustomBehaviorOption = { type: string; key?: string; [key: string]: unknown }; export type CustomBehaviorOption = LooselyModuleOption;
type Abbr<R extends CustomBehaviorOption> = (R & { key?: string }) | R['type'];

View File

@ -1,12 +1,8 @@
import type { LooselyModuleOption, ModuleOptions, STDModuleOption } from '../types';
import type { BuiltInWidgetOptions } from '../widgets/types'; import type { BuiltInWidgetOptions } from '../widgets/types';
export type WidgetOptions = Abbr<BuiltInWidgetOptions | CustomWidgetOptions>[]; export type WidgetOptions = ModuleOptions<BuiltInWidgetOptions>;
type CustomWidgetOptions = STDWidgetOptions; export type STDWidgetOption = STDModuleOption<BuiltInWidgetOptions>;
export interface STDWidgetOptions { export type CustomWidgetOption = LooselyModuleOption;
type: string;
[key: string]: unknown;
}
type Abbr<R extends STDWidgetOptions> = (R & { key?: string }) | R['type'];

View File

@ -1,3 +1,5 @@
export type BehaviorEvent<T extends Event = Event> = T & { import type { FederatedEvent } from '@antv/g';
export type BehaviorEvent<T extends Event | FederatedEvent = Event> = T & {
targetType: 'canvas' | 'node' | 'edge' | 'combo'; targetType: 'canvas' | 'node' | 'edge' | 'combo';
}; };

View File

@ -1,6 +1,6 @@
import { ChangeTypeEnum } from '../constants'; import { ChangeTypeEnum } from '../constants';
import type { ComboData, EdgeData, NodeData } from '../spec/data'; import type { ComboData, EdgeData, NodeData } from '../spec/data';
import { Loose } from './enum'; import { Loosen } from './enum';
/** /**
* <zh/> * <zh/>
@ -16,49 +16,49 @@ export type DataUpdated = NodeUpdated | EdgeUpdated | ComboUpdated;
export type DataRemoved = NodeRemoved | EdgeRemoved | ComboRemoved; export type DataRemoved = NodeRemoved | EdgeRemoved | ComboRemoved;
export type NodeAdded = { export type NodeAdded = {
type: Loose<ChangeTypeEnum.NodeAdded>; type: Loosen<ChangeTypeEnum.NodeAdded>;
value: NodeData; value: NodeData;
}; };
export type NodeUpdated = { export type NodeUpdated = {
type: Loose<ChangeTypeEnum.NodeUpdated>; type: Loosen<ChangeTypeEnum.NodeUpdated>;
value: NodeData; value: NodeData;
original: NodeData; original: NodeData;
}; };
export type NodeRemoved = { export type NodeRemoved = {
type: Loose<ChangeTypeEnum.NodeRemoved>; type: Loosen<ChangeTypeEnum.NodeRemoved>;
value: NodeData; value: NodeData;
}; };
export type EdgeAdded = { export type EdgeAdded = {
type: Loose<ChangeTypeEnum.EdgeAdded>; type: Loosen<ChangeTypeEnum.EdgeAdded>;
value: EdgeData; value: EdgeData;
}; };
export type EdgeUpdated = { export type EdgeUpdated = {
type: Loose<ChangeTypeEnum.EdgeUpdated>; type: Loosen<ChangeTypeEnum.EdgeUpdated>;
value: EdgeData; value: EdgeData;
original: EdgeData; original: EdgeData;
}; };
export type EdgeRemoved = { export type EdgeRemoved = {
type: Loose<ChangeTypeEnum.EdgeRemoved>; type: Loosen<ChangeTypeEnum.EdgeRemoved>;
value: EdgeData; value: EdgeData;
}; };
export type ComboAdded = { export type ComboAdded = {
type: Loose<ChangeTypeEnum.ComboAdded>; type: Loosen<ChangeTypeEnum.ComboAdded>;
value: ComboData; value: ComboData;
}; };
export type ComboUpdated = { export type ComboUpdated = {
type: Loose<ChangeTypeEnum.ComboUpdated>; type: Loosen<ChangeTypeEnum.ComboUpdated>;
value: ComboData; value: ComboData;
original: ComboData; original: ComboData;
}; };
export type ComboRemoved = { export type ComboRemoved = {
type: Loose<ChangeTypeEnum.ComboRemoved>; type: Loosen<ChangeTypeEnum.ComboRemoved>;
value: ComboData; value: ComboData;
}; };

View File

@ -1 +1 @@
export type Loose<T extends string> = `${T}`; export type Loosen<T extends string> = `${T}`;

View File

@ -10,6 +10,7 @@ export type * from './enum';
export type * from './event'; export type * from './event';
export type * from './graphlib'; export type * from './graphlib';
export type * from './layout'; export type * from './layout';
export type * from './module';
export type * from './node'; export type * from './node';
export type * from './padding'; export type * from './padding';
export type * from './point'; export type * from './point';

View File

@ -0,0 +1,44 @@
/**
* <zh/>
*
* <en/> Module options
*/
export type ModuleOptions<Registry extends Record<string, unknown> = Record<string, unknown>> = ModuleOption<
Registry | LooselyModuleOption
>[];
/**
* <zh/>
*
* <en/> Module option
*/
type ModuleOption<Registry extends Record<string, unknown> = Record<string, unknown>> = AbbrModuleOption<
LooselyModuleOption<Registry>
>;
/**
* <zh/>
*
* <en/> Standard module options
*/
export type STDModuleOption<Registry extends Record<string, unknown> = Record<string, unknown>> = {
type: string;
key: string;
} & Registry;
/**
* <zh/> key
*
* <en/> Loosely module option, key can be omitted
*/
export type LooselyModuleOption<Registry extends Record<string, unknown> = Record<string, unknown>> = {
type: string;
key?: string;
} & Registry;
/**
* <zh/> type
*
* <en/> Module option abbreviation, support directly passing in type string
*/
type AbbrModuleOption<S extends LooselyModuleOption> = (S & { key?: string }) | S['type'];

View File

@ -1,24 +0,0 @@
import type { BehaviorOptions } from '../spec';
import type { STDBehaviorOption } from '../spec/behavior';
/**
* <zh/>
*
* <en/> Convert behavior options to standard format
* @param behaviors - <zh/> <en/> behavior options
* @returns <zh/> <en/> Standard behavior options
*/
export function parseBehaviors(behaviors: BehaviorOptions): STDBehaviorOption[] {
const counter: Record<string, number> = {};
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) };
});
}

View File

@ -0,0 +1,147 @@
/**
* @file module.ts
* @description
* <zh/> Behavior Widget `模块`
*
* <en/> 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<Module extends BaseModule<LooselyModuleOption>> {
protected context: RuntimeContext;
protected modules: STDModuleOption[] = [];
protected moduleMap: Record<string, Module> = {};
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;
}
}
/**
* <zh/>
*
* <en/> Base class for module instance
*/
export class BaseModule<T extends LooselyModuleOption> {
protected context: RuntimeContext;
protected options: Required<T>;
protected events: [EventEmitter | HTMLElement, string, Listener][] = [];
public destroyed = false;
constructor(context: RuntimeContext, options: T) {
this.context = context;
this.options = options as Required<T>;
}
update(options: Partial<T>) {
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;
}
}
/**
* <zh/>
*
* <en/> Convert module options to standard format
* @param category - <zh/> <en/> module type
* @param modules - <zh/> <en/> module options
* @returns <zh/> <en/> Standard module options
*/
export function parseModules<T extends Record<string, unknown>>(
category: 'widget' | 'behavior',
modules: ModuleOptions<T>,
): STDModuleOption<T>[] {
const counter: Record<string, number> = {};
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<T>[];
}

View File

@ -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<ShortcutKey, Handler> = new Map();
private emitter: EventEmitter;
private recordKey = new Set<string>();
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);
}
}
});
};
}

View File

@ -0,0 +1,6 @@
import type { CustomWidgetOption } from '../spec/widget';
import { BaseModule } from '../utils/module';
export type BaseWidgetOptions = CustomWidgetOption;
export abstract class BaseWidget<T extends BaseWidgetOptions> extends BaseModule<T> {}

View File

@ -1 +1,4 @@
import type { BaseWidget } from './base-widget';
export type BuiltInWidgetOptions = { type: 'unset' }; export type BuiltInWidgetOptions = { type: 'unset' };
export type Widget = BaseWidget<any>;