diff --git a/src/models/interaction.ts b/src/models/interaction.ts index 5e6c6c5..a8fedd6 100644 --- a/src/models/interaction.ts +++ b/src/models/interaction.ts @@ -1,11 +1,23 @@ -import { hoverEdge, hoverNode, selectEdge, selectNode, unhoverAll, unselectAll } from '../utils/graph.utils'; +import { + hoverEdge, + hoverNode, + ISelectionOptions, + selectEdge, + selectNode, + unhoverAll, + unselectAll, + unselectEdge, + unselectNode, +} from '../utils/graph.utils'; import { IEdgeBase } from './edge'; import { IGraph } from './graph'; import { INodeBase } from './node'; export interface IGraphInteraction { - selectNodeById(id: any): boolean; - selectEdgeById(id: any): boolean; + selectNodeById(id: any, options?: ISelectionOptions): boolean; + selectEdgeById(id: any, options?: ISelectionOptions): boolean; + unselectNodeById(id: any, options?: ISelectionOptions): boolean; + unselectEdgeById(id: any, options?: ISelectionOptions): boolean; unselectAll(): number; hoverNodeById(id: any): boolean; hoverEdgeById(id: any): boolean; @@ -19,21 +31,39 @@ export class GraphInteraction implemen this._graph = graph; } - selectNodeById(id: any): boolean { + selectNodeById(id: any, options?: ISelectionOptions): boolean { const node = this._graph.getNodeById(id); if (!node) { return false; } - selectNode(node); + selectNode(node, options); return true; } - selectEdgeById(id: any): boolean { + selectEdgeById(id: any, options?: ISelectionOptions): boolean { const edge = this._graph.getEdgeById(id); if (!edge) { return false; } - selectEdge(edge); + selectEdge(edge, options); + return true; + } + + unselectNodeById(id: any, options?: ISelectionOptions): boolean { + const node = this._graph.getNodeById(id); + if (!node) { + return false; + } + unselectNode(node, options); + return true; + } + + unselectEdgeById(id: any, options?: ISelectionOptions): boolean { + const edge = this._graph.getEdgeById(id); + if (!edge) { + return false; + } + unselectEdge(edge, options); return true; } diff --git a/src/models/strategy.ts b/src/models/strategy.ts index a3580b6..2eb7c36 100644 --- a/src/models/strategy.ts +++ b/src/models/strategy.ts @@ -2,11 +2,21 @@ import { INode, INodeBase } from './node'; import { IEdge, IEdgeBase } from './edge'; import { IGraph } from './graph'; import { IPosition } from '../common'; -import { hoverOnlyNode, selectOnlyEdge, selectOnlyNode, unhoverAll, unselectAll } from '../utils/graph.utils'; +import { + hoverOnlyNode, + selectOnlyEdge, + selectOnlyNode, + toggleEdgeSelection, + toggleNodeSelection, + unhoverAll, + unselectAll, +} from '../utils/graph.utils'; export interface IEventStrategySettings { isDefaultSelectEnabled: boolean; isDefaultHoverEnabled: boolean; + isDefaultMultiSelectEnabled: boolean; + isDefaultSelectCascadeEnabled: boolean; } export interface IEventStrategyResponse { @@ -14,10 +24,20 @@ export interface IEventStrategyResponse | IEdge; } +export interface IEventStrategyClickOptions { + isAppend?: boolean; +} + export interface IEventStrategy { isSelectEnabled: boolean; isHoverEnabled: boolean; - onMouseClick: (graph: IGraph, point: IPosition) => IEventStrategyResponse; + isMultiSelectEnabled: boolean; + isSelectCascadeEnabled: boolean; + onMouseClick: ( + graph: IGraph, + point: IPosition, + options?: IEventStrategyClickOptions, + ) => IEventStrategyResponse; onMouseMove: (graph: IGraph, point: IPosition) => IEventStrategyResponse; onMouseRightClick: (graph: IGraph, point: IPosition) => IEventStrategyResponse; onMouseDoubleClick: (graph: IGraph, point: IPosition) => IEventStrategyResponse; @@ -27,17 +47,31 @@ export class DefaultEventStrategy impl private _lastHoveredNode?: INode; public isSelectEnabled: boolean; public isHoverEnabled: boolean; + public isMultiSelectEnabled: boolean; + public isSelectCascadeEnabled: boolean; constructor(settings: IEventStrategySettings) { this.isSelectEnabled = settings.isDefaultSelectEnabled; this.isHoverEnabled = settings.isDefaultHoverEnabled; + this.isMultiSelectEnabled = settings.isDefaultMultiSelectEnabled; + this.isSelectCascadeEnabled = settings.isDefaultSelectCascadeEnabled; } - onMouseClick(graph: IGraph, point: IPosition): IEventStrategyResponse { + onMouseClick( + graph: IGraph, + point: IPosition, + options?: IEventStrategyClickOptions, + ): IEventStrategyResponse { + const isAppend = this.isMultiSelectEnabled && (options?.isAppend ?? false); + const node = graph.getNearestNode(point); if (node) { if (this.isSelectEnabled) { - selectOnlyNode(graph, node); + if (isAppend) { + toggleNodeSelection(node); + } else { + selectOnlyNode(graph, node, { cascade: this.isSelectCascadeEnabled }); + } } return { @@ -49,7 +83,11 @@ export class DefaultEventStrategy impl const edge = graph.getNearestEdge(point); if (edge) { if (this.isSelectEnabled) { - selectOnlyEdge(graph, edge); + if (isAppend) { + toggleEdgeSelection(edge); + } else { + selectOnlyEdge(graph, edge, { cascade: this.isSelectCascadeEnabled }); + } } return { @@ -58,7 +96,7 @@ export class DefaultEventStrategy impl }; } - if (!this.isSelectEnabled) { + if (!this.isSelectEnabled || isAppend) { return { isStateChanged: false }; } @@ -104,7 +142,7 @@ export class DefaultEventStrategy impl const node = graph.getNearestNode(point); if (node) { if (this.isSelectEnabled) { - selectOnlyNode(graph, node); + selectOnlyNode(graph, node, { cascade: this.isSelectCascadeEnabled }); } return { @@ -116,7 +154,7 @@ export class DefaultEventStrategy impl const edge = graph.getNearestEdge(point); if (edge) { if (this.isSelectEnabled) { - selectOnlyEdge(graph, edge); + selectOnlyEdge(graph, edge, { cascade: this.isSelectCascadeEnabled }); } return { @@ -139,7 +177,7 @@ export class DefaultEventStrategy impl const node = graph.getNearestNode(point); if (node) { if (this.isSelectEnabled) { - selectOnlyNode(graph, node); + selectOnlyNode(graph, node, { cascade: this.isSelectCascadeEnabled }); } return { @@ -151,7 +189,7 @@ export class DefaultEventStrategy impl const edge = graph.getNearestEdge(point); if (edge) { if (this.isSelectEnabled) { - selectOnlyEdge(graph, edge); + selectOnlyEdge(graph, edge, { cascade: this.isSelectCascadeEnabled }); } return { diff --git a/src/utils/graph.utils.ts b/src/utils/graph.utils.ts index b969614..6d0765f 100644 --- a/src/utils/graph.utils.ts +++ b/src/utils/graph.utils.ts @@ -6,22 +6,86 @@ import { IFitZoomTransformOptions } from '../renderer/shared'; import { ILayoutSettings } from '../simulator'; import { IHierarchicalLayoutOptions } from '../simulator/engine/shared'; -export const selectNode = (node: INode) => { - setNodeState(node, GraphObjectState.SELECTED, { isStateOverride: true }); +export interface ISelectionOptions { + cascade?: boolean; +} + +export const selectNode = ( + node: INode, + options?: ISelectionOptions, +) => { + if (options?.cascade ?? true) { + setNodeState(node, GraphObjectState.SELECTED, { isStateOverride: true }); + } else { + node.setState(GraphObjectState.SELECTED, { isNotifySkipped: true }); + } +}; + +export const selectEdge = ( + edge: IEdge, + options?: ISelectionOptions, +) => { + if (options?.cascade ?? true) { + setEdgeState(edge, GraphObjectState.SELECTED, { isStateOverride: true }); + } else { + edge.setState(GraphObjectState.SELECTED, { isNotifySkipped: true }); + } +}; + +export const unselectNode = ( + node: INode, + options?: ISelectionOptions, +) => { + if (options?.cascade ?? true) { + setNodeState(node, GraphObjectState.NONE, { isStateOverride: true }); + } else { + node.clearState(); + } }; -export const selectEdge = (edge: IEdge) => { - setEdgeState(edge, GraphObjectState.SELECTED, { isStateOverride: true }); +export const unselectEdge = ( + edge: IEdge, + options?: ISelectionOptions, +) => { + if (options?.cascade ?? true) { + setEdgeState(edge, GraphObjectState.NONE, { isStateOverride: true }); + } else { + edge.clearState(); + } }; -export const selectOnlyNode = (graph: IGraph, node: INode) => { +export const selectOnlyNode = ( + graph: IGraph, + node: INode, + options?: ISelectionOptions, +) => { unselectAll(graph); - selectNode(node); + selectNode(node, options); }; -export const selectOnlyEdge = (graph: IGraph, edge: IEdge) => { +export const selectOnlyEdge = ( + graph: IGraph, + edge: IEdge, + options?: ISelectionOptions, +) => { unselectAll(graph); - selectEdge(edge); + selectEdge(edge, options); +}; + +export const toggleNodeSelection = (node: INode) => { + if (node.isSelected()) { + unselectNode(node, { cascade: false }); + } else { + selectNode(node, { cascade: false }); + } +}; + +export const toggleEdgeSelection = (edge: IEdge) => { + if (edge.isSelected()) { + unselectEdge(edge, { cascade: false }); + } else { + selectEdge(edge, { cascade: false }); + } }; export const unselectAll = ( diff --git a/src/views/orb-map-view.ts b/src/views/orb-map-view.ts index 91b0f4f..5d59819 100644 --- a/src/views/orb-map-view.ts +++ b/src/views/orb-map-view.ts @@ -108,6 +108,8 @@ export class OrbMapView implements IOr strategy: { isDefaultHoverEnabled: true, isDefaultSelectEnabled: true, + isDefaultMultiSelectEnabled: false, + isDefaultSelectCascadeEnabled: true, ...settings?.strategy, }, }; @@ -115,6 +117,8 @@ export class OrbMapView implements IOr this._strategy = new DefaultEventStrategy({ isDefaultSelectEnabled: this._settings.strategy.isDefaultSelectEnabled ?? false, isDefaultHoverEnabled: this._settings.strategy.isDefaultHoverEnabled ?? false, + isDefaultMultiSelectEnabled: this._settings.strategy.isDefaultMultiSelectEnabled ?? true, + isDefaultSelectCascadeEnabled: this._settings.strategy.isDefaultSelectCascadeEnabled ?? true, }); try { @@ -201,6 +205,16 @@ export class OrbMapView implements IOr this._settings.strategy.isDefaultSelectEnabled = settings.strategy.isDefaultSelectEnabled; this._strategy.isSelectEnabled = this._settings.strategy.isDefaultSelectEnabled; } + + if (isBoolean(settings.strategy.isDefaultMultiSelectEnabled)) { + this._settings.strategy.isDefaultMultiSelectEnabled = settings.strategy.isDefaultMultiSelectEnabled; + this._strategy.isMultiSelectEnabled = this._settings.strategy.isDefaultMultiSelectEnabled; + } + + if (isBoolean(settings.strategy.isDefaultSelectCascadeEnabled)) { + this._settings.strategy.isDefaultSelectCascadeEnabled = settings.strategy.isDefaultSelectCascadeEnabled; + this._strategy.isSelectCascadeEnabled = this._settings.strategy.isDefaultSelectCascadeEnabled; + } } } @@ -349,7 +363,9 @@ export class OrbMapView implements IOr this._renderer.render(this._graph); } } else if (event.type === 'click') { - const response = this._strategy.onMouseClick(this._graph, point); + const response = this._strategy.onMouseClick(this._graph, point, { + isAppend: event.originalEvent.shiftKey, + }); const subject = response.changedSubject; if (subject) { diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 532d38e..61b1efc 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -85,6 +85,8 @@ export class OrbView implements IOrbVi strategy: { isDefaultHoverEnabled: true, isDefaultSelectEnabled: true, + isDefaultMultiSelectEnabled: false, + isDefaultSelectCascadeEnabled: true, ...settings?.strategy, }, interaction: { @@ -110,6 +112,8 @@ export class OrbView implements IOrbVi this._strategy = new DefaultEventStrategy({ isDefaultSelectEnabled: this._settings.strategy.isDefaultSelectEnabled ?? false, isDefaultHoverEnabled: this._settings.strategy.isDefaultHoverEnabled ?? false, + isDefaultMultiSelectEnabled: this._settings.strategy.isDefaultMultiSelectEnabled ?? true, + isDefaultSelectCascadeEnabled: this._settings.strategy.isDefaultSelectCascadeEnabled ?? true, }); try { @@ -249,6 +253,16 @@ export class OrbView implements IOrbVi this._settings.strategy.isDefaultSelectEnabled = settings.strategy.isDefaultSelectEnabled; this._strategy.isSelectEnabled = this._settings.strategy.isDefaultSelectEnabled; } + + if (isBoolean(settings.strategy.isDefaultMultiSelectEnabled)) { + this._settings.strategy.isDefaultMultiSelectEnabled = settings.strategy.isDefaultMultiSelectEnabled; + this._strategy.isMultiSelectEnabled = this._settings.strategy.isDefaultMultiSelectEnabled; + } + + if (isBoolean(settings.strategy.isDefaultSelectCascadeEnabled)) { + this._settings.strategy.isDefaultSelectCascadeEnabled = settings.strategy.isDefaultSelectCascadeEnabled; + this._strategy.isSelectCascadeEnabled = this._settings.strategy.isDefaultSelectCascadeEnabled; + } } // Check if interaction settings are provided @@ -468,7 +482,9 @@ export class OrbView implements IOrbVi const mousePoint = this.getCanvasMousePosition(event); const simulationPoint = this._renderer.getSimulationPosition(mousePoint); - const response = this._strategy.onMouseClick(this._graph, simulationPoint); + const response = this._strategy.onMouseClick(this._graph, simulationPoint, { + isAppend: event.shiftKey, + }); const subject = response.changedSubject; if (subject) {