/*! * Copyright (c) 2023, 2025, Oracle and/or its affiliates. */ /** * @file * * The main controller handling basically all the interactions in the diagram. * * Exceptions: * Selection is handled in the SelectionController. * Keyboard interactions are handled in the KeyboardController. */ // FUTURE_IMPROVEMENT: // 1. In the next version(s) we should split up the logic for dragging and dropping elements, // consider some dragndrop controller that would hightlight and validate all the necessary things; // currently the logic is in this file and it is too complex & messy, especially after we added // the container and subcontainer logic. // 2. Setting read-only on the subcontainers and containers still allows cells to be added/dropped/embedded // into them. Is this something that needs to change? import Controller from "./Controller.mjs"; import { highlightView, unhighlightView } from "../actions/highlight.mjs"; import * as diaActions from "../actions/dia.mjs"; import * as toolActions from "../actions/tool.mjs"; import { getAnchorCoords, getLinkEndAtRatio, getLoopedLinksOfElement, isChildOf, isContainer, isSubcontainer, validateEmbed } from "../utils/graph.mjs"; import Link from "../cells/links/Link.mjs"; import Element from "../cells/elements/Element.mjs"; import TargetPlaceholder from "../tools/links/TargetPlaceholder.mjs"; import SourcePlaceholder from "../tools/links/SourcePlaceholder.mjs"; import { showToast } from "../actions/ui.mjs"; import DiagramBuilderLocale from "../DiagramBuilderLocale.mjs"; import { deselect, isSelected, select } from "../actions/selection.mjs"; import DiagramBuilderSubcontainerBehavior from "../DiagramBuilderSubcontainerBehavior.mjs"; import ContainerElement from "../cells/elements/ContainerElement.mjs"; import DiagramBuilderSelectionMode from "../DiagramBuilderSelectionMode.mjs"; const SubcontainerBehavior = DiagramBuilderSubcontainerBehavior; const oppositeSides = { left: "right", right: "left", top: "bottom", bottom: "top" }; const LINK_LENGTH = 160; const PLACEHOLDER_ALLOW_DROP_CLS = "allow-drop"; const PLACEHOLDER_HIGHLIGHT_COLOR_STROKE = "var(--a-diagram-link-placeholder-highlight-border-color, none)"; const PLACEHOLDER_HIGHLIGHT_COLOR_FILL = "var(--a-diagram-link-placeholder-highlight-background-color, orange)"; const PLACEHOLDER_HIGHLIGHT_COLOR_GLYPH = "var(--a-diagram-link-placeholder-highlight-text-color, #000)"; let overCellHighlightId; export default class ViewController extends Controller { #paperScrollerResizeObserver; startListening() { const { graph, paper, stencil, paperScroller, snaplines, navigator, selection, filterEmbeds } = this.context; // default values: Object.assign( this.context, { // NOTE: the context obj ref MUST stay the same! allowDropConnectOnElement: () => { }, allowDropConnectOnLink: () => { }, allowDropConnectOnPlaceholder: () => { }, ...this.context, // We must rewrite the above defaults if they've been set expandedContainer: null, trackingContainerElementPositionChange: false, preventContainerElementExpand: false } ); this.stopListening(); this.listenTo( paper, { "blank:pointerdown": onPaperBlankPointerDown, "cell:mouseenter": onCellMouseEnter, "cell:mouseleave": onCellMouseLeave, "link:pointerup": onLinkPointerUp, "link:placeholder:mouseenter": onLinkPlaceholderMouseEnter, "link:placeholder:mouseleave": onLinkPlaceholderMouseLeave, "link:placeholder:pointerup": onLinkPlaceholderPointerup, "link:placeholder:pointerdown": onLinkPlaceholderPointerdown, "element:connectbutton:linkingstart": onElementConnectBtnLinkingStart, "element:pointerdown": onElementPointerdown, "element:pointerup": onElementPointerup, "scale": onPaperScale, "containerelement:expandbutton:pointerdown": onContainerElementExpandBtnPointerDown, "containerelement:expandbutton:pointerup": onContainerElementExpandBtnPointerUp } ); this.listenTo( graph, { "readonly": onReadOnlyChange, "reset": onGraphReset, "add": onCellAdd, "remove": onCellRemove, "beforeremove": onBeforeRemove, "change:position": onPositionChange, "change:size": onSizeChange, "change:designation": onLinkDesignationChange, "change:source": onLinkSourceChange, "change:target": onLinkTargetChange, "change:vertices": onLinkVerticesChange, "change:expanded": onContainerElementExpandedChange, "change:embeds": onEmbedsChange, "change:readOnly": onCellReadOnlyChange, "embedsfit": onEmbedsFit } ); this.listenTo( stencil, { "element:beforedrop": onElementBeforeDrop, "element:dragstart": onElementDragStart, "element:drop": onElementDrop, "element:dragend": onElementDragEnd, "element:drag": onElementDrag } ); this.listenTo( navigator, { "visibility": onNavVisibilityChange } ); this.listenTo( selection, { "change": onSelectionChange } ); // Not using the ui.Keyboard to catch ESC press to stop-drag as it cannot handle // capture phase. this.maybeCancelDrag = this.maybeCancelDrag.bind( this ); document.addEventListener( "keydown", this.maybeCancelDrag, true ); // Keeps the navigator in sync after resizing the paper-scroller. this.#paperScrollerResizeObserver = new ResizeObserver( () => { // this function is debounced navigator.updateCurrentView(); } ); this.#paperScrollerResizeObserver.observe( paperScroller.el ); // add drop validation function stencil.options.validateDrop = ( cloneView, point ) => { const { expandedContainer, paper } = this.context; const { model } = cloneView; if ( !expandedContainer ) { return true; } else { // Containers cannot be dropped anywhere when there is an expanded container. if ( isContainer( model ) || filterEmbeds.includes( model.get( "typeId" ) ) ) { return false; } // allow dropping only to the expanded container // note: findElementsAtPoint finds even a collapsed container's children. const modelsUnderPointer = graph.findElementsAtPoint( paper.clientToLocalPoint( point ) ); if ( !modelsUnderPointer.includes( expandedContainer ) ) { return false; } else { return modelsUnderPointer.at( -1 ) !== expandedContainer; } } }; // Return true if the element should not be considered for snapping. // NOTE: There is additional hiding of snaplines after validating drops on link, element, etc. // FUTURE_IMPROVEMENT: We should try to simplify this in the future. The highlighting logic and snaplines disabling is quite // confusing at the moment. snaplines.options.filter = snaplines.filterFunction = ( snapElement, cell ) => { const { hoveredView } = this.context; // if the dragged cell is a subcontainer if ( isSubcontainer( cell ) ) { const sibling = isSubcontainer( snapElement ) && snapElement.getParentCell() === cell.getParentCell(); return !sibling; } // if the snapped-to cell is a subcontainer, do not show snaplines else if ( isSubcontainer( snapElement ) ) { return true; } // for snapping only inside the subcontainer else if ( hoveredView ) { const { model: hoveredModel } = hoveredView; if ( isSubcontainer( hoveredModel ) ) { return snapElement.getParentCell() !== hoveredModel; } } return cell.getParentCell() !== snapElement.getParentCell(); }; } maybeCancelDrag( e ) { const { stencil, stencilDraggedView } = this.context; if ( stencilDraggedView && e.key === "Escape" ) { e.stopImmediatePropagation(); stencil.cancelDrag(); } } useKeyboard( bool ) { const { paper, readOnly, hoveredView, selection, connectToolHidingDistance } = this.context; const { collection } = selection; const selectedModel = collection.length === 1 ? collection.at( 0 ) : null; const hoveredModel = hoveredView ? hoveredView.model : null; const isHoveredModelSelected = ( hoveredModel && selectedModel === hoveredModel ); this.context.usingKeyboard = bool; // going to keyboard nav if ( bool ) { // if a view was hovered but it is not the selected one, remove the hover tools if ( hoveredView && !isHoveredModelSelected ) { toolActions.removeTools( hoveredView ); } // do it only if exactly one cell is selected if ( selectedModel ) { const selectedIsEl = selectedModel.isElement(); const selectedView = selectedModel.findView( paper ); const toolSetName = selectedIsEl ? toolActions.elementToolSetNames.ELEMENT_SELECTED_HOVERED : toolActions.linkToolSetNames.LINK_SELECTED; // if it doesn't have the toolset, add it back on; this can happen e.g. when // we hover another cell with the mouse and then use the keyboard to reposition if ( !toolActions.hasToolSet( selectedView, toolSetName ) ) { if ( selectedIsEl ) { toolActions.addElementTools( selectedView, toolSetName, readOnly || selectedModel.isReadOnly(), { connectToolHidingDistance } ); } else { toolActions.addLinkTools( selectedView, toolSetName, readOnly || selectedModel.isReadOnly() ); } } } } // returning from keyboard nav to mouse: else { // If an element was selected by the keyboard and it is not the hovered view after switching to mouse // we have to remove the tools as elements are not using ELEMENT_SELECTED toolset. On cell-out the tools // are removed. if ( selectedModel && selectedModel.isElement() && selectedModel !== hoveredModel ) { toolActions.removeTools( selectedModel.findView( paper ) ); } // ...and if something is hovered, show tools on that view if ( hoveredView ) { if ( hoveredModel.isElement() ) { toolActions.addElementTools( hoveredView, isHoveredModelSelected ? toolActions.elementToolSetNames.ELEMENT_SELECTED_HOVERED : toolActions.elementToolSetNames.ELEMENT_HOVERED, readOnly || hoveredModel.isReadOnly(), { connectToolHidingDistance } ); } else { toolActions.addLinkTools( hoveredView, isHoveredModelSelected ? toolActions.linkToolSetNames.LINK_SELECTED : toolActions.linkToolSetNames.LINK_HOVERED, readOnly || hoveredModel.isReadOnly() ); } } } } hasExpandedContainer() { return !!this.context.expandedContainer; } getExpandedContainer() { return this.context.expandedContainer; } destroy() { super.destroy(); this.#paperScrollerResizeObserver.unobserve( this.context.paperScroller.el ); document.removeEventListener( "keydown", this.maybeCancelDrag ); } } function onSelectionChange( ctx, selection, { added } ) { const { expandedContainer } = ctx; if ( expandedContainer && added.length ) { // every newly added model must be either embedded in the expanded container // or must be the expanded container itself; if not, the expended must be collapsed const valid = added.every( model => model === expandedContainer || model.getAncestors().includes( expandedContainer ) ); if ( !valid ) { expandedContainer.collapse(); } } } function onEmbedsChange( ctx, model ) { const { subcontainerBehavior } = ctx; if ( subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE && isSubcontainer( model ) ) { resizeContainersStartingWith( model, { keepMainContainerPosition: false } ); } } function onElementConnectBtnLinkingStart( ctx ) { const { selection } = ctx; deselect( selection ); } function onReadOnlyChange( ctx, readOnly ) { const { graph, paper, paperScroller } = ctx; ctx.readOnly = readOnly; graph.getLinks().forEach( link => { const view = link.findView( paper ); if ( view ) { if ( readOnly ) { toolActions.removeLinkSourcePlaceholder( view ); toolActions.removeLinkTargetPlaceholder( view ); } else if ( !link.isReadOnly() && !link.isDisabled() ) { if ( !link.getSourceCell() ) { toolActions.addLinkSourcePlaceholder( view ); } if ( !link.getTargetCell() ) { toolActions.addLinkTargetPlaceholder( view ); } } } } ); paperScroller.adjustPaper(); } function onNavVisibilityChange( ctx, visibility ) { const { navigator } = ctx; const visible = visibility === "visible"; // pass it around ctx.navigatorVisible = visible; if ( visible ) { diaActions.updateAllConnections( navigator.targetPaper ); } } function onPaperScale( ctx, sx ) { const { locale, style, el } = ctx; showToast( `${locale[DiagramBuilderLocale.STR_ZOOM_LEVEL].replaceAll( "{0}", Math.round( sx * 10 ) * 10 )}`, { foregroundContainerCls: style.toastForegroundContainerCls, target: el } ); } function onElementPointerdown( ctx, view ) { ctx.paperDraggedElementView = view; } function onElementPointerup( ctx ) { ctx.paperDraggedElementView = null; } function onElementDragStart( ctx, cloneView ) { ctx.stencilDraggedView = cloneView; } function onElementDrag( ctx, view, e ) { const { hoveredView, dropConnectOnElements } = ctx; if ( hoveredView ) { const hoveredModel = ctx.hoveredView.model; if ( !hoveredModel.isElement() || !dropConnectOnElements || validateDropOnElement( ctx, hoveredView, view ) === false ) { return; } addElementDropConnectDatasetFromEvent( ctx, e ); } } function onElementDragEnd( ctx ) { ctx.stencilDraggedView = null; } function onElementBeforeDrop( ctx, cloneView, endClone, e, x, y, hoveredView ) { if ( hoveredView ) { const { stencil } = ctx; const { model } = hoveredView; // as hovered view might be a shape embedded in a subcontainer, we need to check the parent, too const subcontainer = ( isSubcontainer( model ) && model ) || ( isSubcontainer( model.getParentCell() ) && model.getParentCell() ); if ( subcontainer ) { stencil.setDropEventOpts( { parentCell: subcontainer } ); } } } function onElementDrop( ctx, view, e, x, y, hoveredView, hoveredTool ) { const { paper, graph, dropConnectOnElements, dropConnectOnLinks, snaplines } = ctx; // We are interested only in drops over links and elements so if it is over // blank, just return and let the default built-in functionality happen. if ( !hoveredView || hoveredView.model.isReadOnly() ) { return; } const { model } = view; const { model: hoveredModel } = hoveredView; if ( overCellHighlightId != null ) { unhighlightView( hoveredView, overCellHighlightId ); } if ( hoveredModel.isElement() ) { // dropped over another element but the the drop over the element is not allowed if ( !dropConnectOnElements || validateDropOnElement( ctx, hoveredView, view, true ) === false ) { return; } // allowed drop over another (non-container) element else { const { x: elX, y: elY } = hoveredModel.position(); const side = elementSide( hoveredModel, x - elX, y - elY ); const { x: hX, y: hY } = hoveredModel.position(); const { width: hElW, height: hElH } = hoveredModel.size(); const { width: newElW, height: newElH } = model.size(); let posX = hX; let posY = hY; switch ( side ) { case "left": posX = hX - LINK_LENGTH - newElW; posY = hY + ( hElH - newElH ) / 2; break; case "right": posX = hX + hoveredModel.size().width + LINK_LENGTH; posY = hY + ( hElH - newElH ) / 2; break; case "bottom": posX = hX + ( hElW - newElW ) / 2; posY = hY + hoveredModel.size().height + LINK_LENGTH; break; default: // 'top' posX = hX + ( hElW - newElW ) / 2; posY = hY - LINK_LENGTH - newElH; } model.position( posX, posY ); const link = paper.getDefaultLink(); // x and y are not important here as it is passed only to connection strategy... where we don't rely on it // because the side is passed so the link is set relatively without x and y. link.set( { source: hoveredView.getLinkEnd( hoveredView.el, x, y, link, "source", side ), target: view.getLinkEnd( view.el, x, y, link, "target", oppositeSides[side] ), } ); graph.addCell( link ); } } else { const ratio = hoveredView.getClosestPointRatio( { x, y } ); if ( isPlaceholderTool( hoveredTool ) ) { if ( validateDropOnPlaceholder( ctx, hoveredView, view, hoveredTool, true ) === false ) { return; } } else if ( !dropConnectOnLinks || validateDropOnLink( ctx, hoveredView, view, true, ratio, getLinkEndAtRatio( hoveredView, ratio ) ) === false ) { return; } const tool = toolActions.getToolByEl( hoveredView, e.target ); if ( isPlaceholderTool( tool ) ) { const ratio = tool.ratio; if ( ratio === 0 ) { hoveredModel.source( model ); } else if ( ratio === 1 ) { hoveredModel.target( model ); } else { addElementToLinkAtRatio( view, hoveredView, ratio ); } } else { const ratio = hoveredView.getClosestPointRatio( { x, y } ); addElementToLinkAtRatio( view, hoveredView, ratio ); } } snaplines.enable(); } function elementSide( el, x, y ) { const sides = ["left", "right", "top", "bottom"]; const { width, height } = el.size(); const y1 = height * x / width; // y of 1st diagonal at x const y2 = height - y1; // y of 2nd diagonal at x return sides[x < 0 || width <= x || y < 0 || height <= y ? -1 : y < y1 ? ( y < y2 ? 2 : 1 ) : ( y < y2 ? 0 : 3 )] || "out"; } function onGraphReset( ctx, { models = [] } ) { const { paper, readOnly } = ctx; ctx.hoveredView = null; ctx.expandedContainer = null; if ( !readOnly ) { models.forEach( model => { if ( model.isLink() && !model.isReadOnly() ) { addLinkPlaceholders( paper, model ); } } ); } } function onLinkDesignationChange( ctx, link, val ) { const { linkDesignations, style } = ctx; const labels = link.labels(); const indexDesignation = labels.findIndex( l => "designation" in l ); // we have an existing label but no new val, should be removed if ( indexDesignation > -1 && val == null ) { link.removeLabel( indexDesignation ); return; } if ( val ) { let designation, tooltip, tooltipPosition; if ( typeof val === "object" ) { ( { typeId: designation, tooltip, tooltipPosition } = val ); } else { designation = val; } // find the designation in the app's config const cfg = linkDesignations.find( cfg => cfg.typeId === designation ); // if we have a cfg, it is allowed if ( cfg ) { const newDesCfg = Link.getLinkDesignationCfg( { distance: 30, glyph: cfg.glyph, designation, tooltip, tooltipPosition, cls: style.linkDesignationCls || [] } ); // update label if ( indexDesignation > -1 ) { link.label( indexDesignation, newDesCfg ); } // create new else { link.insertLabel( 0, newDesCfg ); } } else { link.designation( null ); } } } function onLinkPointerUp( ctx, view, e ) { const { action, arrowhead, originSide // originSide exists only on newly created links } = view.eventData( e ); const { model } = view; if ( action === "arrowhead-move" ) { if ( arrowhead === "source" ) { if ( !model.getSourceCell() ) { toolActions.addLinkSourcePlaceholder( view ); } else if ( toolActions.hasTool( view, toolActions.linkToolNames.SOURCE_PLACEHOLDER ) ) { toolActions.removeLinkSourcePlaceholder( view ); } } else { if ( !model.getTargetCell() ) { toolActions.addLinkTargetPlaceholder( view ); } else if ( toolActions.hasTool( view, toolActions.linkToolNames.TARGET_PLACEHOLDER ) ) { toolActions.removeLinkTargetPlaceholder( view ); } } } if ( originSide && model.hasLoop() ) { diaActions.createLoopingVertices( model, model.getTargetCell(), originSide ); } } function onLinkSourceChange( ctx, model, source, opt ) { const { paper, subcontainerBehavior } = ctx; const view = model.findView( paper ); const action = view._dragData?.action; const positionChangeDueToParent = "translateBy" in opt; const isEmbedded = model.isEmbedded(); const parentEl = isEmbedded ? model.getParentCell() : null; if ( positionChangeDueToParent ) { return; } if ( action === "arrowhead-move" || ( isEmbedded && opt.disallowed ) ) { if ( isEmbedded && subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE ) { resizeContainersStartingWith( parentEl, { keepMainContainerPosition: false } ); } return; } if ( !model.getSourceCell() ) { toolActions.addLinkSourcePlaceholder( view ); } else if ( toolActions.hasTool( view, toolActions.linkToolNames.SOURCE_PLACEHOLDER ) ) { toolActions.removeLinkSourcePlaceholder( view ); } if ( isEmbedded && subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE && !opt.subcontainerShift ) { resizeContainersStartingWith( parentEl, { keepMainContainerPosition: false } ); } } function onLinkTargetChange( ctx, model, target, opt ) { const { paper, subcontainerBehavior } = ctx; const view = model.findView( paper ); const action = view._dragData?.action; const positionChangeDueToParent = "translateBy" in opt; const isEmbedded = model.isEmbedded(); const parentEl = isEmbedded ? model.getParentCell() : null; if ( positionChangeDueToParent ) { return; } if ( action === "arrowhead-move" || ( isEmbedded && opt.disallowed ) ) { if ( isEmbedded && subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE ) { resizeContainersStartingWith( parentEl, { keepMainContainerPosition: false } ); } return; } if ( !model.getTargetCell() ) { toolActions.addLinkTargetPlaceholder( view ); } else if ( toolActions.hasTool( view, toolActions.linkToolNames.TARGET_PLACEHOLDER ) ) { toolActions.removeLinkTargetPlaceholder( view ); } if ( isEmbedded && subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE && !opt.subcontainerShift ) { resizeContainersStartingWith( parentEl, { keepMainContainerPosition: false } ); } } function onLinkVerticesChange( ctx, model, vertices, opt ) { const { subcontainerBehavior } = ctx; const isEmbedded = model.isEmbedded(); const parentEl = isEmbedded ? model.getParentCell() : null; if ( opt.subcontainerShift ) { return; } if ( isEmbedded && subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE ) { resizeContainersStartingWith( parentEl, { keepMainContainerPosition: false } ); } } function onCellAdd( ctx, cell, _, opt = {} ) { const { paper, readOnly, elementProps = {}, linkProps = {}, subcontainerBehavior, filterEmbeds } = ctx; const { ui, restrictToParentBBox } = opt; let { parentCell } = opt; const props = cell.isElement() ? elementProps : linkProps; // Currently applies only to elements as all link props can be set through // the defaultLink option (we have only 1 link type). Will leave this here // for possible future improvement. for ( let prop in props ) { if ( Object.hasOwn( props, prop ) ) { const curr = cell.prop( prop ); // if no prop on the cell, add it if ( curr === undefined ) { cell.prop( prop, elementProps[prop] ); } } } if ( cell.isElement() ) { updateLinkRouting( ctx ); } else { // set placeholders only if it is not dragged from a connect tool if ( !ui && !readOnly && !cell.isReadOnly() ) { addLinkPlaceholders( paper, cell ); } if ( !parentCell ) { parentCell = cell.getSourceCell()?.getParentCell() || cell.getTargetCell()?.getParentCell(); } } if ( parentCell ) { const valid = validateEmbed( parentCell, cell, filterEmbeds ); if ( valid ) { const view = paper.findViewByModel( cell ) ; diaActions.embedCellInto( cell, parentCell, { clipToParent: subcontainerBehavior === SubcontainerBehavior.CLIP } ); // links need to have `restrictToParentBBox` set if ( cell.isElement() || restrictToParentBBox ) { diaActions.restrictToParent( view, { transition: false, suppressEvent: true } ); } } } } function onCellRemove( ctx, cell, collection, opt = {} ) { const { graph, hoveredView, expandedContainer, subcontainerBehavior } = ctx; const { clear } = opt; if ( hoveredView && hoveredView.model === cell ) { ctx.hoveredView = null; } if ( expandedContainer === cell ) { ctx.expandedContainer = null; ctx.expandedContainerPreviousZIndex = null; enableCells( ctx, graph.getCells() ); } if ( !clear && isSubcontainer( cell ) ) { // At this point we cannot get the parent anymore with the usual API as // the cell has already been unembedded. const container = graph.getCell( cell.previous( "parent" ) ); if ( container ) { diaActions.arrangeSubcontainers( container ); // might need to shrink the main container container.resizeToChildren( { keepPosition: subcontainerBehavior !== SubcontainerBehavior.AUTO_RESIZE } ); } }; } function onBeforeRemove( ctx, cell ) { const { graph } = ctx; if ( cell.isElement() ) { const keepOutbound = cell.get( "onRemoveKeepOutboundLinks" ); const keepInbound = cell.get( "onRemoveKeepInboundLinks" ); if ( keepOutbound ) { graph.getConnectedLinks( cell, { outbound: true } ).forEach( function ( link ) { const sourcePoint = getAnchorCoords( link.source().anchor, cell.getBBox() ); link.source( sourcePoint ); } ); } if ( keepInbound ) { graph.getConnectedLinks( cell, { inbound: true } ).forEach( function ( link ) { const targetPoint = getAnchorCoords( link.target().anchor, cell.getBBox() ); link.target( targetPoint ); } ); } } } function addLinkPlaceholders( paper, link ) { const s = link.getSourceCell(); const t = link.getTargetCell(); const view = link.findView( paper ); if ( !s ) { toolActions.addLinkSourcePlaceholder( view ); } if ( !t ) { toolActions.addLinkTargetPlaceholder( view ); } } function onPositionChange( ctx, model, pos, opt = {} ) { const { subcontainerBehavior, paper, readOnly, connectToolHidingDistance } = ctx; const positionChangeDueToParent = "translateBy" in opt ? opt.translateBy !== model.id : false; const parentEl = model.getParentCell(); const parentIsSubcontainer = parentEl ? isSubcontainer( parentEl ) : false; // prevent expanding the container if it has moved if ( ctx.trackingContainerElementPositionChange ) { ctx.trackingContainerElementPositionChange = false; ctx.preventContainerElementExpand = true; } // if the element has a looped link, update the vertices const loopedLinks = getLoopedLinksOfElement( model ); if ( loopedLinks.length ) { const { x: prevX, y: prevY } = model.previous( "position" ); loopedLinks.forEach( link => { link.vertices( link.vertices().map( vertex => { return { x: vertex.x + ( pos.x - prevX ), y: vertex.y + ( pos.y - prevY ) }; } ) ); } ); } updateLinkRouting( ctx ); if ( positionChangeDueToParent ) { return; } const view = paper.findViewByModel( model ); const activeToolSet = toolActions.getActiveToolSetName( view ); if ( subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE ) { if ( parentIsSubcontainer ) { resizeContainersStartingWith( parentEl, { keepMainContainerPosition: false } ); } } // the main container always needs to resize when the subcontainer changes position: else if ( isSubcontainer( model ) ) { resizeContainersStartingWith( parentEl ); } if ( parentIsSubcontainer && activeToolSet ) { toolActions.addElementTools( view, activeToolSet, readOnly || model.isReadOnly(), { connectToolHidingDistance } ); } } function onSizeChange( ctx, model, size, opt = {} ) { const { paper, subcontainerBehavior, navigator } = ctx; const { moveSiblings = true, deepMove = true } = opt; updateLinkRouting( ctx ); if ( isSubcontainer( model ) ) { const parentEl = model.getParentCell(); if ( subcontainerBehavior === SubcontainerBehavior.CLIP ) { const children = model.getEmbeddedCells(); children.forEach( child => { paper.findViewByModel( child ).updateClipping(); navigator.targetPaper.findViewByModel( child ).updateClipping(); } ); resizeContainersStartingWith( parentEl ); if ( moveSiblings ) { shiftSubcontainers( model, deepMove ); } } else if ( subcontainerBehavior === SubcontainerBehavior.RESTRICT_TRANSLATE ) { resizeContainersStartingWith( parentEl ); if ( moveSiblings ) { shiftSubcontainers( model, deepMove ); } } } // Embedded in subcontainer: else if ( model.isEmbedded() ) { if ( subcontainerBehavior === SubcontainerBehavior.RESTRICT_TRANSLATE ) { diaActions.restrictToParent( paper.findViewByModel( model ) ); } else if ( subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE ) { resizeContainersStartingWith( model.getParentCell(), { keepMainContainerPosition: false } ); } } } function onCellReadOnlyChange( ctx, cell ) { const { selection, readOnly } = ctx; if ( !readOnly && isSubcontainer( cell ) && isSelected( selection, cell ) ) { // reselect to redraw the halo (in selection controller) deselect( selection ); select( selection, cell ); } } function onEmbedsFit( ctx, cell ) { if ( ctx.subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE ) { adjustOtherSubcontainers( cell ); shiftSubcontainers( cell ); } } function updateLinkRouting( { paper, navigator, navigatorVisible } ) { diaActions.updateAutoRoutedConnections( paper ); if ( navigator && navigatorVisible ) { diaActions.updateAutoRoutedConnections( navigator.targetPaper ); } } function addElementDropConnectDatasetFromEvent( ctx, e ) { const view = ctx.hoveredView; const { model } = view; const { paper } = ctx; const { x, y } = paper.clientToLocalPoint( e.clientX, e.clientY ); const { x: elX, y: elY } = model.position(); view.el.dataset.dropConnectSide = elementSide( model, x - elX, y - elY ); } function onCellMouseEnter( ctx, view, e ) { const { selection, readOnly, dropConnectOnElements, dropConnectOnLinks, stencilDraggedView, snaplines, usingKeyboard, style, expandedContainer, filterEmbeds, connectToolHidingDistance } = ctx; const { collection } = selection; const { model, el } = view; const selected = collection.length === 1 && collection.has( model ); if ( ctx.hoveredView === view ) { return; } ctx.hoveredView = view; if ( usingKeyboard ) { return; } if ( style.hoverCls?.length && !stencilDraggedView ) { const { hoverCls } = style; el.classList.add( ...( Array.isArray( hoverCls ) ? hoverCls : hoverCls.split( " " ) ) ); } if ( isCellSelectable( ctx, model ) ) { el.classList.add( "selectable" ); } else { el.classList.remove( "selectable" ); } if ( model.isElement() ) { if ( stencilDraggedView && !model.isReadOnly() ) { // If the dropped-on model is embedded, this cell needs to be embedded as well. Validate it before progressing // further to check dropConnectOnElements. Snaplines should not be visible when the drop is not valid. if ( model.isEmbedded() ) { const subcontainer = isSubcontainer( model ) ? model : model.getParentCell(); // over subcontainer if ( subcontainer === model ) { if ( validateEmbed( subcontainer, stencilDraggedView.model, filterEmbeds ) ) { overCellHighlightId = highlightView( view, { layer: null, padding: 4 } ); addElementDropConnectDatasetFromEvent( ctx, e ); } else { snaplines.disable(); snaplines.hide(); } } // over other cell in the subcontainer else { if ( validateEmbed( subcontainer, stencilDraggedView.model, filterEmbeds ) && dropConnectOnElements && validateDropOnElement( ctx, view, stencilDraggedView ) !== false ) { overCellHighlightId = highlightView( view ); addElementDropConnectDatasetFromEvent( ctx, e ); snaplines.disable(); snaplines.hide(); } } return; } if ( dropConnectOnElements && validateDropOnElement( ctx, view, stencilDraggedView ) !== false ) { overCellHighlightId = highlightView( view ); addElementDropConnectDatasetFromEvent( ctx, e ); snaplines.disable(); // One may expect disable would also hide but it is not so... snaplines.hide(); } } // Split up for better readability: if ( !stencilDraggedView ) { if ( expandedContainer ) { // If the hovered view is the expanded container or a child item of that container. if ( model === expandedContainer || isChildOf( model, expandedContainer ) ) { toolActions.addElementTools( view, selected ? toolActions.elementToolSetNames.ELEMENT_SELECTED_HOVERED : toolActions.elementToolSetNames.ELEMENT_HOVERED, readOnly || model.isReadOnly(), { connectToolHidingDistance } ); } // Else: do not show tools as an element outside of the container was hovered. } // In case there is no expanded container, every not read-only element can show tools. else { toolActions.addElementTools( view, selected ? toolActions.elementToolSetNames.ELEMENT_SELECTED_HOVERED : toolActions.elementToolSetNames.ELEMENT_HOVERED, readOnly || model.isReadOnly(), { connectToolHidingDistance } ); } } } else { if ( !model.isReadOnly() ) { const tool = toolActions.getToolByEl( view, e.target ); // if it is a placeholder tool, it is handled by onLinkPlaceholderMouseEnter & onLinkPlaceholderMouseLeave // if not: if ( !isPlaceholderTool( tool ) && dropConnectOnLinks && stencilDraggedView ) { if ( model.isEmbedded() ) { const subcontainer = model.getParentCell(); if ( validateEmbed( subcontainer, stencilDraggedView.model, filterEmbeds ) && dropConnectOnLinks && validateDropOnLink( ctx, view, stencilDraggedView ) !== false ) { overCellHighlightId = highlightView( view ); } else { snaplines.disable(); snaplines.hide(); } return; } if ( dropConnectOnLinks && validateDropOnLink( ctx, view, stencilDraggedView ) !== false ) { overCellHighlightId = highlightView( view ); snaplines.disable(); snaplines.hide(); } } } // Split up for better readability: if ( !stencilDraggedView ) { if ( expandedContainer ) { // If the hovered view is the expanded container or a child item of that container. if ( model === expandedContainer || isChildOf( model, expandedContainer ) ) { toolActions.addLinkTools( view, selected ? toolActions.linkToolSetNames.LINK_SELECTED : toolActions.linkToolSetNames.LINK_HOVERED, readOnly || model.isReadOnly() ); } // Else: do not show tools as a link outside of the container was hovered. } // In case there is no expanded container, every not read-only link can show tools. else { toolActions.addLinkTools( view, selected ? toolActions.linkToolSetNames.LINK_SELECTED : toolActions.linkToolSetNames.LINK_HOVERED, readOnly || model.isReadOnly() ); } } } } function onLinkPlaceholderPointerdown( ctx, view, tool, ratio, point, e ) { e.stopPropagation(); } function onLinkPlaceholderPointerup( ctx, view, tool, ratio ) { const { paperDraggedElementView } = ctx; const link = view.model; if ( paperDraggedElementView && validateDropOnPlaceholder( ctx, view, paperDraggedElementView, tool, true ) !== false ) { if ( ratio === 0 ) { link.source( paperDraggedElementView.model ); } else if ( ratio === 1 ) { link.target( paperDraggedElementView.model ); } else { addElementToLinkAtRatio( paperDraggedElementView, view, ratio ); } } } function onLinkPlaceholderMouseEnter( ctx, view, tool, ratio, point, e ) { const { paper, dropConnectOnLinks, stencilDraggedView, paperDraggedElementView, snaplines, filterEmbeds } = ctx; const { relatedTarget } = e; const { relatedView } = tool; const { model: link } = relatedView; const comingFromView = paper.findView( relatedTarget ); const draggedView = stencilDraggedView || paperDraggedElementView; // At this point cell mouse enter has not yet fired, so hoveredView is null! // We need to use the view arg. if ( draggedView ) { if ( link.isEmbedded() ) { const subcontainer = link.getParentCell(); if ( validateEmbed( subcontainer, draggedView.model, filterEmbeds ) && validateDropOnPlaceholder( ctx, view, draggedView, tool ) !== false ) { tool.el.classList.add( PLACEHOLDER_ALLOW_DROP_CLS ); // Show highlight colors tool.setColors( { stroke: PLACEHOLDER_HIGHLIGHT_COLOR_STROKE, fill: PLACEHOLDER_HIGHLIGHT_COLOR_FILL, glyph: PLACEHOLDER_HIGHLIGHT_COLOR_GLYPH } ); } } else if ( validateDropOnPlaceholder( ctx, view, draggedView, tool ) !== false ) { tool.el.classList.add( PLACEHOLDER_ALLOW_DROP_CLS ); // Show highlight colors tool.setColors( { stroke: PLACEHOLDER_HIGHLIGHT_COLOR_STROKE, fill: PLACEHOLDER_HIGHLIGHT_COLOR_FILL, glyph: PLACEHOLDER_HIGHLIGHT_COLOR_GLYPH } ); } } // unhighlight the linkview if dropping on it is allowed if ( dropConnectOnLinks && comingFromView && comingFromView === view && stencilDraggedView ) { unhighlightView( comingFromView, overCellHighlightId ); snaplines.enable(); } } function onLinkPlaceholderMouseLeave( ctx, view, tool, ratio, point, e ) { const { paper, dropConnectOnLinks, hoveredView, stencilDraggedView, snaplines, filterEmbeds } = ctx; const { relatedTarget } = e; const goingToView = paper.findView( relatedTarget ); const subcontainer = goingToView?.model.getParentCell(); tool.el.classList.remove( PLACEHOLDER_ALLOW_DROP_CLS ); tool.resetColors(); if ( dropConnectOnLinks && goingToView && goingToView === hoveredView && stencilDraggedView && ( !subcontainer || validateEmbed( subcontainer, stencilDraggedView.model, filterEmbeds ) ) && validateDropOnLink( ctx, goingToView, stencilDraggedView ) !== false ) { overCellHighlightId = highlightView( goingToView ); snaplines.disable(); snaplines.hide(); } } function onCellMouseLeave( ctx, view ) { const { selection, readOnly, paper, dropConnectOnLinks, dropConnectOnElements, snaplines, usingKeyboard, style } = ctx; const { model, el } = view; const { collection } = selection; const selected = collection.length === 1 && collection.has( model ); if ( readOnly ) { el.style = ""; } el.classList.remove( "selectable" ); ctx.hoveredView = null; if ( usingKeyboard ) { return; } if ( style.hoverCls?.length ) { const { hoverCls } = style; el.classList.remove( ...( Array.isArray( hoverCls ) ? hoverCls : hoverCls.split( " " ) ) ); } if ( model.isElement() ) { if ( dropConnectOnElements ) { if ( overCellHighlightId != null ) { unhighlightView( view, overCellHighlightId ); } snaplines.enable(); } // We will always remove tools - even tho we have ELEMENT_SELECTED set, it should all be hidden on mouse out // but will still need it to be displayed for keyboard navigation. toolActions.removeTools( view ); } if ( model.isLink() ) { if ( dropConnectOnLinks ) { if ( overCellHighlightId != null ) { unhighlightView( view, overCellHighlightId ); } snaplines.enable(); } if ( selected ) { paper.requireView( model ); toolActions.addLinkTools( view, toolActions.linkToolSetNames.LINK_SELECTED, readOnly || model.isReadOnly() ); } else { toolActions.removeTools( view, true ); } } } function onPaperBlankPointerDown( ctx, e, x, y ) { const { paperScroller, keyboard } = ctx; if ( !keyboard.isActive( "shift", e ) && !paperScroller.isLocked() ) { paperScroller.startPanning( e, x, y ); } } function onContainerElementExpandBtnPointerDown( ctx ) { ctx.trackingContainerElementPositionChange = true; } function onContainerElementExpandBtnPointerUp( ctx, view, e ) { ctx.trackingContainerElementPositionChange = false; if ( !ctx.preventContainerElementExpand && !e.ctrlKey && !e.metaKey ) { view.model.toggle(); } ctx.preventContainerElementExpand = false; } function isPlaceholderTool( tool ) { return tool && ( tool instanceof SourcePlaceholder || tool instanceof TargetPlaceholder ); } function validateDropOnLink( ctx, hoveredView, draggedView, isDrop = false, ratio, ends ) { const { readOnly, allowDropConnectOnLink, expandedContainer, filterEmbeds } = ctx; const link = hoveredView.model; const draggedEl = draggedView.model; const linkIsEmbdedded = link.isEmbedded(); if ( readOnly || link.isReadOnly() || !link.allow( Link.ALLOW_TARGET_CHANGE ) || !draggedEl.allow( Element.ALLOW_LINK_IN ) || !draggedEl.allow( Element.ALLOW_LINK_OUT ) || expandedContainer && !linkIsEmbdedded || link.isEmbedded() && !validateEmbed( link.getParentCell(), draggedEl, filterEmbeds ) ) { return false; } return allowDropConnectOnLink( hoveredView, draggedView, isDrop, ratio, ends ); } function validateDropOnElement( ctx, hoveredView, draggedView, isDrop = false ) { const { readOnly, allowDropConnectOnElement, expandedContainer, filterEmbeds } = ctx; const el = hoveredView.model; const draggedEl = draggedView.model; const elementIsEmbedded = el.isEmbedded(); if ( readOnly || el.isReadOnly() || !el.allow( Element.ALLOW_LINK_OUT ) || !draggedEl.allow( Element.ALLOW_LINK_IN ) || expandedContainer && !elementIsEmbedded || el.isEmbedded() && !validateEmbed( el.getParentCell(), draggedEl, filterEmbeds ) ) { return false; } return allowDropConnectOnElement( hoveredView, draggedView, isDrop ); } function validateDropOnPlaceholder( ctx, linkView, draggedView, tool, isDrop = false ) { const { readOnly, allowDropConnectOnPlaceholder } = ctx; const link = linkView.model; const draggedEl = draggedView.model; const ratio = tool.ratio; // NOTE: we do not need to check whether it is embedded as unembedded links do not show placeholders if ( readOnly || link.isReadOnly() ) { return false; } if ( ratio === 0 ) { if ( !link.allow( Link.ALLOW_SOURCE_CHANGE ) || !draggedEl.allow( Element.ALLOW_LINK_OUT ) ) { return false; } } else if ( ratio === 1 ) { if ( !link.allow( Link.ALLOW_TARGET_CHANGE ) || !draggedEl.allow( Element.ALLOW_LINK_IN ) ) { return false; } } else { if ( !link.allow( Link.ALLOW_TARGET_CHANGE ) || !draggedEl.allow( Element.ALLOW_LINK_OUT ) || !draggedEl.allow( Element.ALLOW_LINK_IN ) ) { return false; } } return allowDropConnectOnPlaceholder( linkView, draggedView, tool, isDrop ); } function addElementToLinkAtRatio( view, linkView, ratio ) { diaActions.addElementToLinkAtRatio( view, linkView, ratio ); } function onContainerElementExpandedChange( ctx, model, expanded ) { const { selection, paper, readOnly, connectToolHidingDistance } = ctx; const { collection } = selection; const view = paper.findViewByModel( model ); if ( expanded ) { popupContainer( ctx, model ); // if multiple items are selected (even if 1 of them is the container), select only the container; if ( collection.length > 1 ) { select( selection, model ); } // Redraws/updates the tools toolActions.addElementTools( view, toolActions.elementToolSetNames.ELEMENT_SELECTED_HOVERED, readOnly || model.isReadOnly(), { connectToolHidingDistance } ); } else { revertContainer( ctx ); // If the selection was inside of the (container) element, move it to the (container) element // after collapsing. if ( collection.length && model.getEmbeddedCells( { deep: true } ).includes( collection.at( 0 ) ) ) { select( selection, model ); } else { if ( isSelected( selection, model ) ) { // Redraws/updates the tools toolActions.addElementTools( view, toolActions.elementToolSetNames.ELEMENT_SELECTED_HOVERED, readOnly || model.isReadOnly(), { connectToolHidingDistance } ); } } } } function popupContainer( ctx, model ) { const { graph, expandedContainer } = ctx; if ( expandedContainer ) { if ( model === expandedContainer ) { return; } expandedContainer.collapse(); } ctx.expandedContainer = model; ctx.expandedContainerPreviousZIndex = model.get( "z" ); diaActions.toFront( model, { deep: true, poppedUpContainer: true } ); disableCells( ctx, graph.getCells().filter( cell => { if ( cell.isEmbedded() ) { return !cell.getAncestors().includes( model ); } else { return cell !== model; } } ) ); } function revertContainer( ctx ) { const { graph, expandedContainer: model } = ctx; // We do NOT need to care about the children's Z index as the children will be visible only when expanded model.set( "z", ctx.expandedContainerPreviousZIndex, { revertedContainer: true } ); ctx.expandedContainer = null; ctx.expandedContainerPreviousZIndex = null; enableCells( ctx, graph.getCells() ); } function disableCells( ctx, cells = [] ) { const { paper, readOnly } = ctx; cells.forEach( cell => { cell.disable(); if ( cell.isLink() && !cell.isReadOnly() && !readOnly ) { const view = paper.findViewByModel( cell ); // We are going to remove the placeholder instead of hiding them because as they are in the top layer, // they would always render above the expanded container. if ( toolActions.hasTool( view, toolActions.linkToolNames.SOURCE_PLACEHOLDER ) ) { toolActions.removeLinkSourcePlaceholder( view ); } if ( toolActions.hasTool( view, toolActions.linkToolNames.TARGET_PLACEHOLDER ) ) { toolActions.removeLinkTargetPlaceholder( view ); } } } ); } function enableCells( ctx, cells = [] ) { const { paper, readOnly } = ctx; cells.forEach( cell => { cell.enable(); if ( cell.isLink() && !cell.isReadOnly() && !readOnly ) { const view = paper.findViewByModel( cell ); if ( !cell.getSourceCell() ) { toolActions.addLinkSourcePlaceholder( view ); } if ( !cell.getTargetCell() ) { toolActions.addLinkTargetPlaceholder( view ); } } } ); } function resizeContainersStartingWith( model, opt = {} ) { const { keepMainContainerPosition = true } = opt; const containers = [model, ...model.getAncestors()]; for ( let i = 0; i < containers.length; i++ ) { const container = containers[i]; if ( !isSubcontainer( container ) && !isContainer( container ) ) { return; } const opt = isContainer( container ) ? { keepPosition: keepMainContainerPosition } : undefined; container.resizeToChildren( opt ); } } function adjustOtherSubcontainers( model ) { if ( !isSubcontainer( model ) ) { return; } const { y: subY } = model.position(); const allSubs = model.getParentCell().getEmbeddedCells(); const otherSubs = allSubs.filter( subcontainer => subcontainer !== model ); otherSubs.forEach( otherSub => { const { x, y } = otherSub.position(); if ( y > subY ) { otherSub.position( x, subY, { subcontainerShift: true, deep: true } ); } } ); } function shiftSubcontainers( subcontainer, deep = true ) { if ( !isSubcontainer( subcontainer ) ) { return; } const index = subcontainer.positionIndex(); const spacing = ContainerElement.CHILDREN_SPACING; const allSubs = subcontainer.getParentCell().getEmbeddedCells(); const prevSubs = allSubs.filter( sub => { const idx = sub.positionIndex(); return idx < index; } ); const nextSubs = allSubs.filter( sub => { const idx = sub.positionIndex(); return idx > index; } ); // prev subs sorted desc prevSubs.sort( ( a, b ) => b.positionIndex() - a.positionIndex() ); // next subs sorted asc nextSubs.sort( ( a, b ) => a.positionIndex() - b.positionIndex() ); prevSubs.forEach( ( sub, index ) => { const nextSub = index === 0 ? subcontainer : prevSubs[index - 1]; const { x: nextX, y } = nextSub.position(); const { width } = sub.size(); sub.position( nextX - width - spacing, y, { subcontainerShift: true, deep } ); } ); nextSubs.forEach( ( sub, index ) => { const prevSub = index === 0 ? subcontainer : nextSubs[index - 1]; const { x: prevX, y } = prevSub.position(); const { width: prevWidth } = prevSub.size(); sub.position( prevX + prevWidth + spacing, y, { subcontainerShift: true, deep } ); } ); } function isCellSelectable( ctx, cell ) { const { selectionMode, filterSelection = () => false } = ctx; return selectionMode !== DiagramBuilderSelectionMode.NONE && cell.isSelectable() && !filterSelection( cell ); }