refactor: refactor click select (#6029)

* feat(utils): add statesOf util

* refactor: adjust getData return type

* refactor(behaviors): refactor click-select
This commit is contained in:
Aaron 2024-07-12 10:10:10 +08:00 committed by GitHub
parent 81414f3a3f
commit dcd7d36c76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 152 additions and 94 deletions

View File

@ -71,8 +71,8 @@ describe('behavior click-select element', () => {
it('multiple', async () => {
graph.setBehaviors([{ type: 'click-select', multiple: true, degree: 0 }]);
graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' });
graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' });
graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' });
graph.emit(NodeEvent.CLICK, { target: { id: '1' }, targetType: 'node' });
graph.emit(CommonEvent.KEY_UP, { key: 'shift' });
@ -80,8 +80,8 @@ describe('behavior click-select element', () => {
graph.setBehaviors([{ type: 'click-select', multiple: true, trigger: ['meta'] }]);
graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' });
graph.emit(CommonEvent.KEY_DOWN, { key: 'meta' });
graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' });
graph.emit(NodeEvent.CLICK, { target: { id: '1' }, targetType: 'node' });
graph.emit(CommonEvent.KEY_UP, { key: 'meta' });

View File

@ -0,0 +1,18 @@
import { statesOf } from '@/src/utils/state';
describe('state', () => {
it('statesOf', () => {
expect(
statesOf({
id: 'node-1',
}),
).toEqual([]);
expect(
statesOf({
id: 'node-1',
states: ['selected'],
}),
).toEqual(['selected']);
});
});

View File

@ -288,8 +288,7 @@ export class BrushSelect extends BaseBehavior<BrushSelectOptions> {
const graphData = graph.getData();
itemTypes.forEach((itemType) => {
const data = graphData[`${itemType}s`];
data?.forEach((datum) => {
graphData[`${itemType}s`].forEach((datum) => {
const id = idOf(datum);
if (graph.getElementVisibility(id) !== 'hidden' && isPointInPolygon(graph.getElementPosition(id), points)) {
elements.push(id);

View File

@ -1,13 +1,13 @@
import { isFunction } from '@antv/util';
import { CanvasEvent, CommonEvent, GraphEvent } from '../constants';
import { CanvasEvent, CommonEvent } from '../constants';
import { ELEMENT_TYPES } from '../constants/element';
import type { RuntimeContext } from '../runtime/types';
import type { ElementType, ID, IPointerEvent, State } from '../types';
import type { ElementLifeCycleEvent } from '../utils/event';
import { idOf, idsOf } from '../utils/id';
import type { Element, ElementType, ID, IPointerEvent, State } from '../types';
import { idOf } from '../utils/id';
import { getElementNthDegreeIds } from '../utils/relation';
import type { ShortcutKey } from '../utils/shortcut';
import { Shortcut } from '../utils/shortcut';
import { statesOf } from '../utils/state';
import type { BaseBehaviorOptions } from './base-behavior';
import { BaseBehavior } from './base-behavior';
@ -108,10 +108,6 @@ export interface ClickSelectOptions extends BaseBehaviorOptions {
* <en/> When the mouse clicks on an element, you can activate the state of the element, such as selecting nodes or edges. When the degree is 1, clicking on a node will highlight the current node and its directly adjacent nodes and edges.
*/
export class ClickSelect extends BaseBehavior<ClickSelectOptions> {
private select: Set<ID> = new Set<ID>();
private neighbor: Set<ID> = new Set<ID>();
private shortcut: Shortcut;
static defaultOptions: Partial<ClickSelectOptions> = {
@ -138,20 +134,17 @@ export class ClickSelect extends BaseBehavior<ClickSelectOptions> {
graph.on(`${type}:${CommonEvent.CLICK}`, this.onClickSelect);
});
graph.on(CanvasEvent.CLICK, this.onClickCanvas);
graph.on(GraphEvent.AFTER_ELEMENT_UPDATE, this.syncState);
}
private onClickSelect = (event: IPointerEvent) => {
private onClickSelect = async (event: IPointerEvent<Element>) => {
if (!this.validate(event)) return;
this.updateState(event);
await this.updateState(event);
this.options.onClick?.(event);
};
private onClickCanvas = (event: IPointerEvent) => {
private onClickCanvas = async (event: IPointerEvent) => {
if (!this.validate(event)) return;
this.updateState(event);
this.select.clear();
this.neighbor.clear();
await this.clearState();
this.options.onClick?.(event);
};
@ -160,96 +153,135 @@ export class ClickSelect extends BaseBehavior<ClickSelectOptions> {
return multiple && this.shortcut.match(trigger);
}
/**
* <zh/> syncState
*
* <en/> syncState will ignore state updates caused by interactive operations
*/
private updating = false;
private getNeighborIds(event: IPointerEvent<Element>) {
const { target, targetType } = event;
const { graph } = this.context;
const { degree } = this.options;
return getElementNthDegreeIds(
graph,
targetType as ElementType,
target.id,
typeof degree === 'function' ? degree(event) : degree,
).filter((id) => id !== target.id);
}
private async updateState(event: IPointerEvent) {
const { state: select, unselectedState: unselect, neighborState: neighbor, animation, degree } = this.options;
if (!select && !unselect) return;
private async updateState(event: IPointerEvent<Element>) {
const { state: selectState, unselectedState, neighborState, animation } = this.options;
if (!selectState && !neighborState && !unselectedState) return;
const target = event.target;
const { target } = event;
const { graph } = this.context;
if ('id' in target) {
const id = target.id;
const datum = graph.getElementData(id);
if (datum?.states?.includes(select)) {
this.select.delete(id);
} else {
if (!this.isMultipleSelect) this.select.clear();
this.select.add(id);
}
}
// 点击了空白处 / click canvas
else this.select.clear();
const datum = graph.getElementData(target.id);
const type = statesOf(datum).includes(selectState) ? 'unselect' : 'select';
const states: Record<ID, State[]> = {};
if (select) {
const exclude = [unselect, neighbor];
this.select.forEach((id) => {
const state = graph.getElementState(id);
states[id] = uniq([...state.filter((s) => !exclude.includes(s)), select]);
});
}
const isMultipleSelect = this.isMultipleSelect;
const neighborIds = new Set<ID>();
if (neighbor) {
const d = typeof degree === 'function' ? degree(event) : degree;
if (d) {
const targetType = event.targetType as ElementType;
this.select.forEach((id) => {
getElementNthDegreeIds(graph, targetType, id, d).forEach((id) => {
if (!this.select.has(id)) neighborIds.add(id);
const click = [target.id];
const neighbor = this.getNeighborIds(event);
if (!isMultipleSelect) {
if (type === 'select') {
Object.assign(states, this.getClearStates(!!unselectedState));
const addState = (list: ID[], state: State) => {
list.forEach((id) => {
if (!states[id]) states[id] = [];
states[id].push(state);
});
};
addState(click, selectState);
addState(neighbor, neighborState);
if (unselectedState) {
Object.keys(states).forEach((id) => {
if (!click.includes(id) && !neighbor.includes(id)) states[id].push(unselectedState);
});
}
} else Object.assign(states, this.getClearStates());
} else {
Object.assign(states, this.getDataStates());
if (type === 'select') {
const addState = (list: ID[], state: State) => {
list.forEach((id) => {
const datum = graph.getElementData(id);
const dataStatesSet = new Set(statesOf(datum));
dataStatesSet.add(state);
dataStatesSet.delete(unselectedState);
states[id] = Array.from(dataStatesSet);
});
};
addState(click, selectState);
addState(neighbor, neighborState);
if (unselectedState) {
Object.keys(states).forEach((id) => {
const _states = states[id];
if (
!_states.includes(selectState) &&
!_states.includes(neighborState) &&
!_states.includes(unselectedState)
) {
states[id].push(unselectedState);
}
});
}
} else {
const targetState = states[target.id];
states[target.id] = targetState.filter((s) => s !== selectState && s !== neighborState);
if (!targetState.includes(unselectedState)) states[target.id].push(unselectedState);
neighbor.forEach((id) => {
states[id] = states[id].filter((s) => s !== neighborState);
if (!states[id].includes(selectState)) states[id].push(unselectedState);
});
}
const exclude = [select, unselect];
neighborIds.forEach((id) => {
const state = graph.getElementState(id);
states[id] = uniq([...state.filter((s) => !exclude.includes(s)), neighbor]);
});
}
const exclude = [select, neighbor, unselect];
idsOf(graph.getData(), true).forEach((id) => {
if (!this.select.has(id) && !neighborIds.has(id)) {
const state = graph.getElementState(id);
const filtered = state.filter((s) => !exclude.includes(s));
// 仅在有选中元素时应用 unselect 状态
// Apply unselect state only when there are selected elements
if (unselect && this.select.size) states[id] = uniq([...filtered, unselect]);
else states[id] = filtered;
}
await graph.setElementState(states, animation);
}
private getDataStates() {
const { graph } = this.context;
const { nodes, edges, combos } = graph.getData();
const states: Record<ID, State[]> = {};
[...nodes, ...edges, ...combos].forEach((data) => {
states[idOf(data)] = statesOf(data);
});
this.updating = true;
await graph.setElementState(states, animation);
this.updating = false;
return states;
}
/**
* <zh/>
* <zh/>
*
* <en/> Sync state
* @remarks
* <zh/> this.select
*
* <en/> Avoid inconsistency between this.select and the actual state after other operations update the state
* @param event - <zh/> | <en/> Element life cycle event
* <en/> Get the states that need to be cleared
* @param complete - <zh/> | <en/> Whether to return all states
* @returns - <zh/> | <en/> States that need to be cleared
*/
private syncState = (event: ElementLifeCycleEvent) => {
if (this.updating) return;
const { data } = event;
const id = idOf(data);
const states = data.states || [];
if (states.includes(this.options.state)) this.select.add(id);
else this.select.delete(id);
};
private getClearStates(complete = false) {
const { graph } = this.context;
const { state, unselectedState, neighborState } = this.options;
const statesToClear = new Set([state, unselectedState, neighborState]);
const { nodes, edges, combos } = graph.getData();
const states: Record<ID, State[]> = {};
[...nodes, ...edges, ...combos].forEach((data) => {
const datumStates = statesOf(data);
const newStates = datumStates.filter((s) => !statesToClear.has(s));
if (complete) states[idOf(data)] = newStates;
else if (newStates.length !== datumStates.length) states[idOf(data)] = newStates;
});
return states;
}
private async clearState() {
const { graph } = this.context;
await graph.setElementState(this.getClearStates(), this.options.animation);
}
private validate(event: IPointerEvent) {
if (this.destroyed) return false;
@ -265,7 +297,6 @@ export class ClickSelect extends BaseBehavior<ClickSelectOptions> {
graph.off(`${type}:${CommonEvent.CLICK}`, this.onClickSelect);
});
graph.off(CanvasEvent.CLICK, this.onClickCanvas);
graph.off(GraphEvent.AFTER_ELEMENT_UPDATE, this.syncState);
}
public destroy() {
@ -273,5 +304,3 @@ export class ClickSelect extends BaseBehavior<ClickSelectOptions> {
super.destroy();
}
}
const uniq = <T>(array: T[]): T[] => Array.from(new Set(array));

View File

@ -290,7 +290,7 @@ export class DataController {
public setData(data: GraphData) {
const { nodes: modifiedNodes = [], edges: modifiedEdges = [], combos: modifiedCombos = [] } = data;
const { nodes: originalNodes = [], edges: originalEdges = [], combos: originalCombos = [] } = this.getData();
const { nodes: originalNodes, edges: originalEdges, combos: originalCombos } = this.getData();
const nodeDiff = arrayDiff(originalNodes, modifiedNodes, (node) => idOf(node));
const edgeDiff = arrayDiff(originalEdges, modifiedEdges, (edge) => idOf(edge));

View File

@ -513,7 +513,7 @@ export class Graph extends EventEmitter {
* <en/> Get the data of the current graph, including node, edge, and combo data
* @apiCategory data
*/
public getData(): GraphData {
public getData(): Required<GraphData> {
return this.context.model.getData();
}

View File

@ -0,0 +1,12 @@
import type { ElementDatum } from '../types';
/**
* <zh/>
*
* <en/> Get the state of the element
* @param datum - <zh/> <en/> Element data
* @returns <zh/> <en/> State list
*/
export function statesOf(datum: ElementDatum) {
return datum.states || [];
}