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';

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

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 { Listener } from '../types';
import { BaseModule } from '../utils/module';
export type BaseBehaviorOptions = CustomBehaviorOption;
export abstract class BaseBehavior<T extends BaseBehaviorOptions> {
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;
}
}
export abstract class BaseBehavior<T extends BaseBehaviorOptions> extends BaseModule<T> {}

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';
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;

View File

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

View File

@ -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 {
*
* <en/> Whether to enable the function of zooming the canvas
*/
enable?: boolean | ((event: BehaviorEvent) => boolean);
enable?: boolean | ((event: BehaviorEvent<WheelEvent> | BehaviorEvent<KeyboardEvent>) => boolean);
/**
* <zh/>
*
* <en/> The way to trigger zoom
* @description
* <zh/>
* - 'wheel'
* - ['ctrl'] ctrl
* - { zoomIn: ['ctrl', '+'], zoomOut: ['ctrl', '-'], reset: ['ctrl', '0'] }
* - 使['Control'] Control
* - { zoomIn: ['Control', '+'], zoomOut: ['Control', '-'], reset: ['Control', '0'] }
*
* <en/>
* - '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<CommonEvent.WHEEL> | string[] | CombinationKey;
trigger?: ShortcutKey | CombinationKey;
/**
* <zh/>
*
@ -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<ZoomCanvasOptions> {
private preconditionKey?: string[];
private recordKey = new Set<string>();
private combinationKey: CombinationKey = {
zoomIn: [],
zoomOut: [],
reset: [],
static defaultOptions: Partial<ZoomCanvasOptions> = {
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<ZoomCanvasOptions> {
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;
}
if (isObject(this.options.trigger)) {
this.combinationKey = this.options.trigger as CombinationKey;
}
this.shortcut = new Shortcut(context.graph);
this.bindEvents();
}
private bindEvents() {
const { graph } = this.context;
public update(options: Partial<ZoomCanvasOptions>): void {
super.update(options);
this.bindEvents();
}
// 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));
private bindEvents() {
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<WheelEvent>) {
private onWheel = (event: BehaviorEvent<WheelEvent>) => {
const { deltaX, deltaY } = event;
const delta = -(deltaY || deltaX);
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/>
@ -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 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;
const { viewport } = this.context;
if (!viewport) return;
@ -148,26 +124,24 @@ export class ZoomCanvas extends BaseBehavior<ZoomCanvasOptions> {
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<WheelEvent> | BehaviorEvent<KeyboardEvent>) {
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);
}
}

View File

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

View File

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

View File

@ -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;
/**
* <zh/>

View File

@ -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<string, BaseBehavior<any>> = {};
export class BehaviorController extends ModuleController<BaseBehavior<CustomBehaviorOption>> {
/** <zh/> 当前事件的目标 | <en/> 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;
}
}

View File

@ -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<G6Spec['widgets']>): void {
public setWidgets(widgets: CallableValue<WidgetOptions>): 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;

View File

@ -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 {
* <en/> Behavior controller
*/
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 { 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 };
type Abbr<R extends CustomBehaviorOption> = (R & { key?: string }) | R['type'];
export type CustomBehaviorOption = LooselyModuleOption;

View File

@ -1,12 +1,8 @@
import type { LooselyModuleOption, ModuleOptions, STDModuleOption } from '../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 {
type: string;
[key: string]: unknown;
}
type Abbr<R extends STDWidgetOptions> = (R & { key?: string }) | R['type'];
export type CustomWidgetOption = LooselyModuleOption;

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

View File

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