@antv/g6
Version:
A Graph Visualization Framework in JavaScript
460 lines (418 loc) • 14.7 kB
text/typescript
import type { BaseStyleProps, Cursor } from '@antv/g';
import { Rect } from '@antv/g';
import { isFunction } from '@antv/util';
import { COMBO_KEY, CanvasEvent, ComboEvent, CommonEvent } from '../constants';
import type { RuntimeContext } from '../runtime/types';
import type { EdgeDirection, ID, IElementDragEvent, IPointerEvent, Point, Prefix, State } from '../types';
import { getBBoxSize, getCombinedBBox } from '../utils/bbox';
import { isToBeDestroyed } from '../utils/element';
import { idOf } from '../utils/id';
import { subStyleProps } from '../utils/prefix';
import { divide, subtract } from '../utils/vector';
import type { BaseBehaviorOptions } from './base-behavior';
import { BaseBehavior } from './base-behavior';
/**
* <zh/> 拖拽元素交互配置项
*
* <en/> Drag element behavior options
*/
export interface DragElementOptions extends BaseBehaviorOptions, Prefix<'shadow', BaseStyleProps> {
/**
* <zh/> 是否启用拖拽动画
*
* <en/> Whether to enable drag animation
* @defaultValue true
*/
animation?: boolean;
/**
* <zh/> 是否启用拖拽节点的功能,默认可以拖拽 node 和 combo
*
* <en/> Whether to enable the function of dragging the node,default can drag node and combo
* @defaultValue ['node', 'combo'].includes(event.targetType)
*/
enable?: boolean | ((event: IElementDragEvent) => boolean);
/**
* <zh/> 拖拽操作效果
* - `'link'`: 将拖拽元素置入为目标元素的子元素
* - `'move'`: 移动元素并更新父元素尺寸
* - `'none'`: 仅更新拖拽目标位置,不做任何额外操作
*
* <en/> Drag operation effect
* - `'link'`: Place the drag element as a child element of the target element
* - `'move'`: Move the element and update the parent element size
* - `'none'`: Only update the drag target position, no additional operations
* @remarks
* <zh/> combo 元素可作为元素容器置入 node 或 combo 元素
*
* <en/> The combo element can be placed as an element container into the node or combo element
* @defaultValue 'move'
*/
dropEffect?: 'link' | 'move' | 'none';
/**
* <zh/> 节点选中的状态,启用多选时会基于该状态查找选中的节点
*
* <en/> The state name of the selected node, when multi-selection is enabled, the selected nodes will be found based on this state
* @defaultValue 'selected'
*/
state?: State;
/**
* <zh/> 拖拽时隐藏的边
* - `'none'`: 不隐藏
* - `'out'`: 隐藏以节点为源节点的边
* - `'in'`: 隐藏以节点为目标节点的边
* - `'both'`: 隐藏与节点相关的所有边
* - `'all'`: 隐藏图中所有边
*
* <en/> Edges hidden during dragging
* - `'none'`: do not hide
* - `'out'`: hide the edges with the node as the source node
* - `'in'`: hide the edges with the node as the target node
* - `'both'`: hide all edges related to the node
* - `'all'`: hide all edges in the graph
* @remarks
* <zh/> 使用幽灵节点时不会隐藏边
*
* <en/> Edges will not be hidden when using the drag shadow
* @defaultValue 'none'
*/
hideEdge?: 'none' | 'all' | EdgeDirection;
/**
* <zh/> 是否启用幽灵节点,即用一个图形代替节点跟随鼠标移动
*
* <en/> Whether to enable the drag shadow, that is, use a shape to replace the node to follow the mouse movement
*/
shadow?: boolean;
/**
* <zh/> 完成拖拽时的回调
*
* <en/> Callback when dragging is completed
*/
onFinish?: (ids: ID[]) => void;
/**
* <zh/> 指针样式
*
* <en/> Cursor style
*/
cursor?: {
/**
* <zh/> 默认指针样式
*
* <en/> Default cursor style
*/
default?: Cursor;
/**
* <zh/> 可抓取指针样式
*
* <en/> Cursor style that can be grabbed
*/
grab: Cursor;
/**
* <zh/> 抓取中指针样式
*
* <en/> Cursor style when grabbing
*/
grabbing: Cursor;
};
}
/**
* <zh/> 拖拽元素交互
*
* <en/> Drag element behavior
*/
export class DragElement extends BaseBehavior<DragElementOptions> {
static defaultOptions: Partial<DragElementOptions> = {
animation: true,
enable: (event) => ['node', 'combo'].includes(event.targetType),
dropEffect: 'move',
state: 'selected',
hideEdge: 'none',
shadow: false,
shadowZIndex: 100,
shadowFill: '#F3F9FF',
shadowFillOpacity: 0.5,
shadowStroke: '#1890FF',
shadowStrokeOpacity: 0.9,
shadowLineDash: [5, 5],
cursor: {
default: 'default',
grab: 'grab',
grabbing: 'grabbing',
},
};
protected enable: boolean = false;
private enableElements = ['node', 'combo'];
protected target: ID[] = [];
private shadow?: Rect;
private shadowOrigin: Point = [0, 0];
private hiddenEdges: ID[] = [];
private isDragging: boolean = false;
constructor(context: RuntimeContext, options: DragElementOptions) {
super(context, Object.assign({}, DragElement.defaultOptions, options));
this.onDragStart = this.onDragStart.bind(this);
this.onDrag = this.onDrag.bind(this);
this.onDragEnd = this.onDragEnd.bind(this);
this.onDrop = this.onDrop.bind(this);
this.bindEvents();
}
/**
* <zh/> 更新元素拖拽配置
*
* <en/> Update the element dragging configuration
* @param options - <zh/> 配置项 | <en/> options
* @internal
*/
public update(options: Partial<DragElementOptions>): void {
this.unbindEvents();
super.update(options);
this.bindEvents();
}
private bindEvents() {
const { graph, canvas } = this.context;
// @ts-expect-error internal property
const $canvas: HTMLCanvasElement = canvas.getLayer().getContextService().$canvas;
if ($canvas) {
$canvas.addEventListener('blur', this.onDragEnd);
$canvas.addEventListener('contextmenu', this.onDragEnd);
}
this.enableElements.forEach((type) => {
graph.on(`${type}:${CommonEvent.DRAG_START}`, this.onDragStart);
graph.on(`${type}:${CommonEvent.DRAG}`, this.onDrag);
graph.on(`${type}:${CommonEvent.DRAG_END}`, this.onDragEnd);
graph.on(`${type}:${CommonEvent.POINTER_ENTER}`, this.setCursor);
graph.on(`${type}:${CommonEvent.POINTER_LEAVE}`, this.setCursor);
});
if (['link'].includes(this.options.dropEffect)) {
graph.on(ComboEvent.DROP, this.onDrop);
graph.on(CanvasEvent.DROP, this.onDrop);
}
}
/**
* <zh/> 获取当前选中的节点 id 集合
*
* <en/> Get the id collection of the currently selected node
* @param currTarget - <zh/> 当前拖拽目标元素 id 集合 | <en/> The id collection of the current drag target element
* @returns <zh/> 当前选中的节点 id 集合 | <en/> The id collection of the currently selected node
* @internal
*/
protected getSelectedNodeIDs(currTarget: ID[]) {
return Array.from(
new Set(
this.context.graph
.getElementDataByState('node', this.options.state)
.map((node) => node.id)
.concat(currTarget),
),
);
}
/**
* Get the delta of the drag
* @param event - drag event object
* @returns delta
* @internal
*/
protected getDelta(event: IElementDragEvent) {
const zoom = this.context.graph.getZoom();
return divide([event.dx, event.dy], zoom);
}
/**
* <zh/> 拖拽开始时的回调
*
* <en/> Callback when dragging starts
* @param event - <zh/> 拖拽事件对象 | <en/> drag event object
* @internal
*/
protected onDragStart(event: IElementDragEvent) {
this.enable = this.validate(event);
if (!this.enable) return;
const { batch, canvas, graph } = this.context;
canvas.setCursor(this.options!.cursor?.grabbing || 'grabbing');
this.isDragging = true;
batch!.startBatch();
// 如果当前节点是选中状态,则查询出画布中所有选中的节点,否则只拖拽当前节点
// If the current node is selected, query all selected nodes in the canvas, otherwise only drag the current node
const id = event.target.id;
const states = graph.getElementState(id);
if (states.includes(this.options.state)) this.target = this.getSelectedNodeIDs([id]);
else this.target = [id];
this.hideEdge();
this.context.graph.frontElement(this.target);
if (this.options.shadow) this.createShadow(this.target);
}
/**
* <zh/> 拖拽过程中的回调
*
* <en/> Callback when dragging
* @param event - <zh/> 拖拽事件对象 | <en/> drag event object
* @internal
*/
protected onDrag(event: IElementDragEvent) {
if (!this.enable) return;
const delta = this.getDelta(event);
if (this.options.shadow) this.moveShadow(delta);
else this.moveElement(this.target, delta);
}
/**
* <zh/> 元素拖拽结束的回调
*
* <en/> Callback when dragging ends
* @internal
*/
protected onDragEnd() {
if (!this.enable) return; // It can be called multiple times
this.enable = false;
if (this.options.shadow) {
if (!this.shadow) return;
this.shadow.style.visibility = 'hidden';
const { x = 0, y = 0 } = this.shadow.attributes;
const [dx, dy] = subtract([+x, +y], this.shadowOrigin);
this.moveElement(this.target, [dx, dy]);
}
this.showEdges();
this.options.onFinish?.(this.target);
const { batch, canvas } = this.context;
batch!.endBatch();
canvas.setCursor(this.options!.cursor?.grab || 'grab');
this.isDragging = false;
this.target = [];
}
/**
* <zh/> 拖拽放下的回调
*
* <en/> Callback when dragging is released
* @param event - <zh/> 拖拽事件对象 | <en/> drag event object
*/
private onDrop = async (event: IElementDragEvent) => {
if (this.options.dropEffect !== 'link') return;
const { model, element } = this.context;
const modifiedParentId = event.target.id;
this.target.forEach((id) => {
const originalParent = model.getParentData(id, COMBO_KEY);
// 如果是在原父 combo 内部拖拽,需要刷新 combo 数据
// If it is a drag and drop within the original parent combo, you need to refresh the combo data
if (originalParent && idOf(originalParent) === modifiedParentId) {
model.refreshComboData(modifiedParentId);
}
model.setParent(id, modifiedParentId, COMBO_KEY);
});
await element?.draw({ animation: true })?.finished;
};
private setCursor = (event: IPointerEvent) => {
if (this.isDragging) return;
const { type } = event;
const { canvas } = this.context;
const { cursor } = this.options;
if (type === CommonEvent.POINTER_ENTER) canvas.setCursor(cursor?.grab || 'grab');
else canvas.setCursor(cursor?.default || 'default');
};
/**
* <zh/> 验证元素是否允许拖拽
*
* <en/> Verify if the element is allowed to be dragged
* @param event - <zh/> 拖拽事件对象 | <en/> drag event object
* @returns <zh/> 是否允许拖拽 | <en/> Whether to allow dragging
* @internal
*/
protected validate(event: IElementDragEvent) {
if (
this.destroyed ||
isToBeDestroyed(event.target) ||
// @ts-expect-error private property
// 避免动画冲突,在combo/node折叠展开过程中不触发
this.context.graph.isCollapsingExpanding
)
return false;
const { enable } = this.options;
if (isFunction(enable)) return enable(event);
return !!enable;
}
/**
* <zh/> 移动元素
*
* <en/> Move the element
* @param ids - <zh/> 元素 id 集合 | <en/> element id collection
* @param offset <zh/> 偏移量 | <en/> offset
* @internal
*/
protected async moveElement(ids: ID[], offset: Point) {
const { graph, model } = this.context;
const { dropEffect } = this.options;
if (dropEffect === 'move') ids.forEach((id) => model.refreshComboData(id));
graph.translateElementBy(Object.fromEntries(ids.map((id) => [id, offset])), false);
}
private moveShadow(offset: Point) {
if (!this.shadow) return;
const { x = 0, y = 0 } = this.shadow.attributes;
const [dx, dy] = offset;
this.shadow.attr({ x: +x + dx, y: +y + dy });
}
private createShadow(target: ID[]) {
const shadowStyle = subStyleProps(this.options, 'shadow');
const bbox = getCombinedBBox(target.map((id) => this.context.element!.getElement(id)!.getBounds()));
const [x, y] = bbox.min;
this.shadowOrigin = [x, y];
const [width, height] = getBBoxSize(bbox);
const positionStyle = { width, height, x, y };
if (this.shadow) {
this.shadow.attr({
...shadowStyle,
...positionStyle,
visibility: 'visible',
});
} else {
this.shadow = new Rect({
style: {
// @ts-ignore $layer is not in the type definition
$layer: 'transient',
...shadowStyle,
...positionStyle,
pointerEvents: 'none',
},
});
this.context.canvas.appendChild(this.shadow);
}
}
private showEdges() {
if (this.options.shadow || this.hiddenEdges.length === 0) return;
this.context.graph.showElement(this.hiddenEdges);
this.hiddenEdges = [];
}
/**
* Hide the edge
* @internal
*/
protected hideEdge() {
const { hideEdge, shadow } = this.options;
if (hideEdge === 'none' || shadow) return;
const { graph } = this.context;
if (hideEdge === 'all') this.hiddenEdges = graph.getEdgeData().map(idOf);
else {
this.hiddenEdges = Array.from(
new Set(this.target.map((id) => graph.getRelatedEdgesData(id, hideEdge).map(idOf)).flat()),
);
}
graph.hideElement(this.hiddenEdges);
}
private unbindEvents() {
const { graph, canvas } = this.context;
// @ts-expect-error internal property
const $canvas: HTMLCanvasElement = canvas.getLayer().getContextService().$canvas;
if ($canvas) {
$canvas.removeEventListener('blur', this.onDragEnd);
$canvas.removeEventListener('contextmenu', this.onDragEnd);
}
this.enableElements.forEach((type) => {
graph.off(`${type}:${CommonEvent.DRAG_START}`, this.onDragStart);
graph.off(`${type}:${CommonEvent.DRAG}`, this.onDrag);
graph.off(`${type}:${CommonEvent.DRAG_END}`, this.onDragEnd);
graph.off(`${type}:${CommonEvent.POINTER_ENTER}`, this.setCursor);
graph.off(`${type}:${CommonEvent.POINTER_LEAVE}`, this.setCursor);
});
graph.off(`combo:${CommonEvent.DROP}`, this.onDrop);
graph.off(`canvas:${CommonEvent.DROP}`, this.onDrop);
}
public destroy() {
this.unbindEvents();
this.shadow?.destroy();
super.destroy();
}
}