/*! * Copyright (c) 2023, 2025, Oracle and/or its affiliates. */ /** * @file * * Controls the selection of the cells in the graph. */ import Controller from "./Controller.mjs"; import { difference as arrayDifference } from "../utils/array.mjs"; import * as toolActions from "../actions/tool.mjs"; import DiagramBuilderSelectionMode from "../DiagramBuilderSelectionMode.mjs"; import { isContainer, isSubcontainer } from "../utils/graph.mjs"; import DiagramBuilderSubcontainerBehavior from "../DiagramBuilderSubcontainerBehavior.mjs"; const SubcontainerBehavior = DiagramBuilderSubcontainerBehavior; const MULTI_SELECTION_BUILT_IN_ACTIONS = ["remove", "rotate", "resize"]; const { ui } = joint; export default class SelectionController extends Controller { startListening() { const { graph, selection, paper } = this.context; const { collection } = selection; // We will use the context object to pass the hovered view to other handlers this.context.hoveredView = null; this.stopListening(); this.listenTo( graph, { "readonly": this.#onReadOnlyChange, "remove": onCellRemove } ); this.listenTo( collection, { "reset": onSelectionReset, "add": onSelectionAdd, "remove": onSelectionRemove } ); this.listenTo( selection, { "change": onSelectionChange // This is triggered in this very file after collection reset, add and remove } ); this.listenTo( paper, { "blank:pointerdown": onBlankPointerDown, "blank:pointerup": onBlankPointerUp, "element:pointerdown": onElementPointerDown, "cell:pointerup": onCellPointerUp, "cell:mouseenter": this.#onCellMouseEnter, "cell:mouseleave": this.#onCellMouseLeave } ); } #onReadOnlyChange( ctx, readOnly ) { ctx.readOnly = readOnly; } #onCellMouseEnter( ctx, view ) { ctx.hoveredView = view; } #onCellMouseLeave( ctx ) { ctx.hoveredView = null; } } function onCellRemove( ctx, cell ) { const { selection } = ctx; const { collection } = selection; if ( collection.has( cell ) ) { collection.remove( cell ); } } function onSelectionAdd( ctx, model, collection ) { const { paper, selection } = ctx; // clearSelectionTools(paper, ); if ( collection.length === 1 ) { const cell = collection.first(); const cellView = paper.requireView( cell ); selectPrimaryCell( ctx, cellView ); } else if ( collection.length > 1 ) { clearSelectionTools( paper, collection.models ); createHandles( ctx, selection, ctx.multiSelectionHandles, MULTI_SELECTION_BUILT_IN_ACTIONS ); } selection.trigger( "change", selection, { added: Array.isArray( model ) ? model : [model], removed: [] } ); } function onSelectionRemove( ctx, model, collection ) { const { paper, selection } = ctx; if ( collection.length === 1 ) { const cell = collection.first(); const cellView = paper.requireView( cell ); selectPrimaryCell( ctx, cellView ); } removeFreeTransform( ctx ); selection.trigger( "change", selection, { added: [], removed: Array.isArray( model ) ? model : [model] } ); } function onSelectionReset( ctx, collection, { previousModels } ) { const { paper, selection } = ctx; const currSelModels = collection.models; const prevSelModels = previousModels; const addedModels = arrayDifference( currSelModels, prevSelModels ); const removedModels = arrayDifference( prevSelModels, currSelModels ); // We must have the following condition before the return because the selection box // is added by jointjs on reset. Thus even if the selection didn't really change, the // box is automatically recreated. if ( collection.length === 1 ) { const cell = collection.first(); const cellView = paper.requireView( cell ); if ( cell.isLink() ) { selection.destroySelectionBox( cell ); } selectPrimaryCell( ctx, cellView ); } else if ( collection.length > 1 ) { createHandles( ctx, selection, ctx.multiSelectionHandles, MULTI_SELECTION_BUILT_IN_ACTIONS ); } if ( !addedModels.length && !removedModels.length ) { return; } clearSelectionTools( paper, removedModels ); removeFreeTransform( ctx ); selection.trigger( "change", selection, { added: addedModels, removed: removedModels } ); } function onSelectionChange( ctx, selection ) { const { subcontainerBehavior, readOnly } = ctx; const { collection } = selection; if ( collection.length === 1 ) { const cell = collection.at( 0 ); if ( isSubcontainer( cell ) && !readOnly && !cell.isReadOnly() && subcontainerBehavior !== SubcontainerBehavior.AUTO_RESIZE ) { createFreeTransform( ctx ); } } } function clearSelectionTools( paper, models = [] ) { models = Array.isArray( models ) ? models : [models]; models.forEach( model => { if ( model.isElement() ) { toolActions.removeTools( paper.findViewByModel( model ) ); } else { toolActions.removeTools( paper.findViewByModel( model ), true ); } } ); } function onCellPointerUp( ctx, cellView, e ) { const { model } = cellView; const { selection, keyboard, mode, selecting, filterSelection = ( /* cell, selectionInitiatedOverModel */ ) => false // returns true when the item can't be selected } = ctx; const { collection } = selection; if ( selecting ) { ctx.selecting = false; return; } const containsLink = model.isLink() || collection.find( cell => cell.isLink() ); const containsSubcontainer = isSubcontainer( model ) || collection.find( cell => isSubcontainer( cell ) ); // eslint-disable-next-line eqeqeq const containsElementWithDifferentParent = collection.at( 0 )?.getParentCell() != model.getParentCell(); const isSelected = collection.includes( model ); const selectable = model.isSelectable() && model.graph; const filterOut = filterSelection( model ); if ( !selectable || filterOut ) { return; } if ( keyboard.isActive( "ctrl meta", e ) ) { if ( mode === DiagramBuilderSelectionMode.SINGLE ) { if ( isSelected ) { collection.reset( [] ); } else { collection.reset( [model] ); } } else { // multi if ( isSelected ) { collection.remove( model ); } else { if ( containsLink || containsElementWithDifferentParent || containsSubcontainer ) { collection.reset( [model] ); } else { collection.add( model ); } } } } else if ( !isSelected ) { collection.reset( [model] ); } } function onBlankPointerDown( ctx, e, x, y ) { const { paper, paperScroller, keyboard, selection, mode } = ctx; if ( mode === DiagramBuilderSelectionMode.MULTI && keyboard.isActive( "shift", e ) ) { selection.startSelecting( e ); } else { clearSelectionTools( paper, selection.collection.models ); selection.collection.reset( [] ); if ( !paperScroller.isLocked() ) { paperScroller.startPanning( e, x, y ); } } } function onBlankPointerUp( ctx ) { ctx.selecting = false; } function onElementPointerDown( ctx, view, e ) { const { keyboard, selection, mode } = ctx; const { model } = view; if ( mode === DiagramBuilderSelectionMode.MULTI && keyboard.isActive( "shift", e ) && isSubcontainer( model ) ) { ctx.selecting = true; view.preventDefaultInteraction( e ); selection.startSelecting( e ); } } function selectPrimaryCell( ctx, view ) { const { hoveredView, readOnly, paper, connectToolHidingDistance } = ctx; const { model } = view; const container = isContainer( model ); if ( model.isElement() ) { if ( !container || !model.isExpanded() ) { const name = hoveredView === view ? toolActions.elementToolSetNames.ELEMENT_SELECTED_HOVERED : toolActions.elementToolSetNames.ELEMENT_SELECTED; toolActions.addElementTools( view, name, readOnly || model.isReadOnly(), { connectToolHidingDistance } ); } } else { paper.requireView( model ); toolActions.addLinkTools( view, toolActions.linkToolSetNames.LINK_SELECTED, readOnly || model.isReadOnly() ); } // else: elements are added tools on pointer-up } function createHandles( ctx, owner, wanted, builtIn ) { const removeHandles = arrayDifference( builtIn, wanted.map( cfg => cfg.name ) ); removeHandles.forEach( handle => owner.removeHandle( handle ) ); wanted.forEach( handle => { if ( hasHandle( owner, handle.name ) ) { const h = getHandle( owner, handle.name ); if ( h.position !== handle.position ) { owner.changeHandle( handle.name, { position: handle.position } ); } } else { owner.addHandle( { name: handle.name, position: handle.position } ); owner.on( `action:${handle.name}:pointerdown`, e => handle.action( ctx, e ) ); } } ); } function hasHandle( owner, name ) { if ( typeof owner.hasHandle === "function" ) { return owner.hasHandle( name ); } else { return owner.handles?.findIndex( handle => handle.name === name ) > -1; } } function getHandle( owner, name ) { if ( typeof owner.getHandle === "function" ) { return owner.getHandle( name ); } else { return owner.handles?.find( handle => handle.name === name ); } } function createFreeTransform( ctx ) { const { paper, selection, graph, subcontainerBehavior } = ctx; const { collection } = selection; const restrictSize = subcontainerBehavior === SubcontainerBehavior.RESTRICT_TRANSLATE; const defaultWidth = 240; const defaultHeight = 240; removeFreeTransform( ctx ); collection.models.forEach( selectedModel => { if ( isSubcontainer( selectedModel ) ) { const { x, y } = selectedModel.position(); const children = selectedModel.getEmbeddedCells(); let cX, cY, cWidth, cHeight; if ( restrictSize ) { ( { x: cX = x, y: cY = y, width: cWidth = 0, height: cHeight = 0 } = children.length ? graph.getCellsBBox( children ) : {} ); } const minWidth = restrictSize ? Math.max( cX + cWidth - x, defaultWidth ) : defaultWidth; const minHeight = restrictSize ? Math.max( cY + cHeight - y, defaultHeight ) : defaultHeight; const view = paper.findViewByModel( selectedModel ); const freeTransform = new ui.FreeTransform( { cellView: view, clearAll: false, resizeDirections: ["bottom", "bottom-right", "right"], allowRotation: false, usePaperScale: true, minWidth, minHeight } ); freeTransform.on( "resize:start", onFreeTransformResizeStart ); freeTransform.on( "resize:stop", onFreeTransformResizeStop ); freeTransform.render(); ctx.freeTransform = freeTransform; } } ); } function removeFreeTransform( ctx ) { const { freeTransform } = ctx; if ( freeTransform ) { freeTransform.off( "resize:start", onFreeTransformResizeStart ); freeTransform.off( "resize:stop", onFreeTransformResizeStop ); freeTransform.remove(); delete ctx.freeTransform; } } function onFreeTransformResizeStart( e ) { // `this` is the FreeTransform const { paper, cellView } = this.options; paper.trigger( "freetransform:resizestart", cellView, e ); } function onFreeTransformResizeStop( e ) { // `this` is the FreeTransform const { paper, cellView } = this.options; paper.trigger( "freetransform:resizestop", cellView, e ); }