/*! * Copyright (c) 2023, 2025, Oracle and/or its affiliates. */ /** * @file * * The main class of the Diagram Builder widget. It instantiantes the models, the views and * their controllers and renders the views into the config-provided HTML element(s). * * Markup requirements: * The main element (config.el) must wrap all the other elements: the paper (mandatory), stencil (optional), * navigator (optional). If it doesn't wrap them, the widget creates new elements for these components * inside of the main element. It will not throw an error. The only error it throws is when the main element * can't be found. E.g.: * * Valid markup (config - el: "#app", navigatorEl: ".my-navigator"): *
*
*
*
*
* * Invalid markup - new elements for the paper and navigator will be created (config - el: "#app", * navigatorEl: ".my-navigator"): *
*
*
*
*
* * Invalid markup - new element for the navigator will be created as the config "navigatorEl" is not * passed and the default value (.navigator) doesn't apply (config - el: "#app"): *
*
*
*
*
*/ import ViewController from "./controllers/ViewController.mjs"; import SelectionController from "./controllers/SelectionController.mjs"; import KeyboardController from "./controllers/KeyboardController.mjs"; import Element from "./cells/elements/Element.mjs"; import Circle from "./cells/elements/CircleElement.mjs"; import CircleView from "./cells/elements/CircleElementView.mjs"; import Rect from "./cells/elements/RectElement.mjs"; import RectView from "./cells/elements/RectElementView.mjs"; import Diamond from "./cells/elements/DiamondElement.mjs"; import DiamondView from "./cells/elements/DiamondElementView.mjs"; import Terminator from "./cells/elements/TerminatorElement.mjs"; import TerminatorView from "./cells/elements/TerminatorElementView.mjs"; import Unconfigured from "./cells/elements/UnconfiguredElement.mjs"; import UnconfiguredView from "./cells/elements/UnconfiguredElementView.mjs"; import CircleSmall from "./cells/elements/CircleElementSmall.mjs"; import DiamondSmall from "./cells/elements/DiamondElementSmall.mjs"; import RectSmall from "./cells/elements/RectElementSmall.mjs"; import SingleLink from "./cells/links/SingleLink.mjs"; import SingleLinkView from "./cells/links/SingleLinkView.mjs"; import Container from "./cells/elements/ContainerElement.mjs"; import ContainerView from "./cells/elements/ContainerElementView.mjs"; import Subcontainer from "./cells/elements/SubcontainerElement.mjs"; import SubcontainerView from "./cells/elements/SubcontainerElementView.mjs"; import * as commonUtils from "./utils/common.mjs"; import * as graphUtils from "./utils/graph.mjs"; import * as highlightActions from "./actions/highlight.mjs"; import * as diaActions from "./actions/dia.mjs"; import * as uiActions from "./actions/ui.mjs"; import Selection from "./Selection.mjs"; import DiagramBuilderEvent from "./DiagramBuilderEvent.mjs"; import { select as selectCells, deselect as deselectCells } from "./actions/selection.mjs"; import PaperScroller from "./PaperScroller.mjs"; import Graph from "./Graph.mjs"; import { createConnectionPoint } from "./utils/connectionPoint.mjs"; import { defineCustomRouters, getRouterConfig } from "./utils/router.mjs"; import DiagramBuilderRouter from "./DiagramBuilderRouter.mjs"; import Link from "./cells/links/Link.mjs"; import Paper from "./Paper.mjs"; import Stencil from "./Stencil.mjs"; import { staticConsts } from "./utils/helpers.mjs"; import DiagramBuilderKeyboardAction from "./DiagramBuilderKeyboardAction.mjs"; import DiagramBuilderSelectionMode from "./DiagramBuilderSelectionMode.mjs"; import DiagramBuilderLocale from "./DiagramBuilderLocale.mjs"; import { isSafari } from "./utils/browser.mjs"; import DiagramBuilderUtil from "./DiagramBuilderUtil.mjs"; import { unique } from "./utils/array.mjs"; import ShapeElement from "./cells/elements/ShapeElement.mjs"; import Snaplines from "./Snaplines.mjs"; import DiagramBuilderSubcontainerBehavior from "./DiagramBuilderSubcontainerBehavior.mjs"; const { dia, ui, shapes, connectionStrategies, g, util, mvc, highlighters } = joint; const SubcontainerBehavior = DiagramBuilderSubcontainerBehavior; export default class DiagramBuilder { #viewController; #selectionController; #keyboardController; #graph; #paper; #paperScroller; #selection; #snaplines; #stencil; #clipboard; #keyboard; #navigator; #elementMap; #elementFilters = []; #suspendedEvents = []; #navigatorVisible; // eslint-disable-next-line no-unused-private-class-members #focused = false; #highlightedRoute = []; #routeHighlightsSave; #el; #paperEl; #stencilEl; #navigatorEl; #linkDesignations; #config = { rtl: document.dir?.toLowerCase() === "rtl", el: null, paperEl: ".paper", stencilEl: ".stencil", navigatorEl: ".navigator", elements: [], stencilElements: [], selectionMode: DiagramBuilderSelectionMode.MULTI, linkDesignations: [], routeHighlights: [], requireLinkSourceElement: false, requireLinkTargetElement: false, readOnly: false, renderStencil: true, onElementRemoveKeepOutboundLinks: false, onElementRemoveKeepInboundLinks: false, dropConnectOnLinks: true, allowDropConnectOnLink: () => { }, dropConnectOnElements: true, allowDropConnectOnElement: () => { }, allowDropConnectOnPlaceholder: () => { }, paperWidth: 2000, paperHeight: 2000, showNavigator: true, navigatorWidth: 192, navigatorHeight: 128, // initialZoom != 0 sets the zoom level only once - on the startup. // initialZoom == 0 zooms out (if necessary) after every load to // try to fit the route or the diagram (if no route) in the viewport. // However, it never zooms in! initialZoom: 1, minZoom: .5, maxZoom: 2, defaultRouter: DiagramBuilderRouter.NORMAL, keyboardNavigation: true, keyboardNavigationMap: undefined, // defaults created in the controller drawGrid: false, filterEmbeds: [], filterSelection: [], subcontainerBehavior: SubcontainerBehavior.RESTRICT_TRANSLATE, style: { elementCls: [], linkCls: [], linkLabelCls: [], linkDesignationCls: [], paperCls: [], stencilCls: [], navigatorCls: [], focusCls: ["is-focused"], hoverCls: ["is-hovered"], toastForegroundContainerCls: [], legacyCls: ["joint-apex-legacy-offset"] // used on the app dom el when a used SVG property is not supported }, locale: { [DiagramBuilderLocale.STR_ZOOM_LEVEL]: "Zoom level {0}%" } }; // The list of properties that can be set on the element-types (note: not instances!) of // the 'elements' config and are subsequently stored in element-instance's attributes. // These are not custom properties (which we don't care about because they are not tied-in // to functionality) but properties which we use in our code. // Set to their default values: get #ELEMENT_TYPES_OPTIONAL_ATTRIBUTES() { return Object.freeze( { allowLinkIn: true, allowLinkOut: true, hasMenu: true, cls: [], onRemoveKeepInboundLinks: this.#config.onElementRemoveKeepInboundLinks, onRemoveKeepOutboundLinks: this.#config.onElementRemoveKeepOutboundLinks } ); } constructor( cfg ) { // For event dispatching util.assign( this, mvc.Events ); util.assign( shapes, { apex: { Circle, CircleView, Rect, RectView, Diamond, DiamondView, Terminator, TerminatorView, Unconfigured, UnconfiguredView, Container, ContainerView, Subcontainer, SubcontainerView, // The views of the small ones are not important (as they have no members) CircleSmall, DiamondSmall, RectSmall, SingleLink, SingleLinkView } } ); this.#linkDesignations = cfg.linkDesignations || []; const _cfg = util.merge( this.#config, cfg ); // i.e. _cfg === this.#config this.#validateConfig(); this.#initEls( _cfg.el ); this.#initGraph(); this.#initPaper(); this.#initRouters(); this.#initSelection(); this.#initKeyboard(); this.#initSnaplines(); this.#initTooltips(); this.#registerElementMap( _cfg.elements ); this.#initStencil(); this.#initNavigator(); this.#initControllers( _cfg ); this.#startEventDispatching(); } #validateConfig() { const cfg = this.#config; // initialZoom 0 means it will zoom out to fit after load if ( cfg.initialZoom !== 0 ) { // make sure the initial zoom is between min and max cfg.initialZoom = Math.min( cfg.maxZoom, Math.max( cfg.initialZoom, cfg.minZoom ) ) || 1; } } #initControllers( cfg ) { const connectToolHidingDistance = cfg.subcontainerBehavior === SubcontainerBehavior.AUTO_RESIZE ? 0 : 20; this.#viewController = new ViewController( { el: this.#el, graph: this.#graph, paper: this.#paper, paperScroller: this.#paperScroller, stencil: this.#stencil, keyboard: this.#keyboard, selection: this.#selection, navigator: this.#navigator, snaplines: this.#snaplines, navigatorVisible: cfg.showNavigator, linkDesignations: this.#linkDesignations, dropConnectOnLinks: cfg.dropConnectOnLinks, allowDropConnectOnLink: cfg.allowDropConnectOnLink, dropConnectOnElements: cfg.dropConnectOnElements, allowDropConnectOnElement: cfg.allowDropConnectOnElement, allowDropConnectOnPlaceholder: cfg.allowDropConnectOnPlaceholder, readOnly: cfg.readOnly, subcontainerBehavior: cfg.subcontainerBehavior, filterEmbeds: cfg.filterEmbeds, style: cfg.style, locale: cfg.locale, elementProps: { onRemoveKeepOutboundLinks: !!cfg.onElementRemoveKeepOutboundLinks, onRemoveKeepInboundLinks: !!cfg.onElementRemoveKeepInboundLinks }, connectToolHidingDistance, filterSelection: ( cell ) => this.#filterSelectedCell( cell ), selectionMode: cfg.selectionMode // as we have only 1 link type, there is no need to have linkProps } ); this.#viewController.startListening(); if ( cfg.selectionMode === DiagramBuilderSelectionMode.MULTI || cfg.selectionMode === DiagramBuilderSelectionMode.SINGLE ) { this.#selectionController = new SelectionController( { graph: this.#graph, paper: this.#paper, paperScroller: this.#paperScroller, selection: this.#selection, multiSelectionHandles: [], // currently unused, leaving as placeholder for possible cfg.multiSelectionHandles keyboard: this.#keyboard, clipboard: this.#clipboard, mode: cfg.selectionMode, readOnly: cfg.readOnly, subcontainerBehavior: cfg.subcontainerBehavior, filterSelection: ( cell ) => this.#filterSelectedCell( cell ), connectToolHidingDistance } ); this.#selectionController.startListening(); } if ( cfg.keyboardNavigation ) { this.#keyboardController = new KeyboardController( { graph: this.#graph, paper: this.#paper, selection: this.#selection, keyboard: this.#keyboard, clipboard: this.#clipboard, paperScroller: this.#paperScroller, selectionMode: cfg.selectionMode, readOnly: cfg.readOnly, map: cfg.keyboardNavigationMap, minZoom: cfg.minZoom, maxZoom: cfg.maxZoom, filterSelection: ( cell ) => this.#filterSelectedCell( cell ), } ); this.#keyboardController.startListening(); this.listenTo( this.#keyboardController, { "change:usage": this.#keyboardUsageChange } ); } } #definePattern( imageUrl ) { return this.#paper.definePattern( { attrs: { width: 60, height: 500 }, markup: [{ tagName: "image", attributes: { "xlink:href": imageUrl, preserveAspectRatio: "xMinYMin slice" } }] } ); } #startEventDispatching() { const E = DiagramBuilderEvent; this.listenTo( this.#graph, { "add": ( cell, collection, opt = {} ) => { const { parentCell } = opt; const eOpt = {}; if ( parentCell ) { eOpt.parentCell = parentCell; eOpt.parentCellId = parentCell.id; } this.#dispatch( cell.isElement() ? E.ELEMENT_ADD : E.LINK_ADD, cell, eOpt ); }, "remove": ( cell, _, { silent = false } ) => { if ( !silent ) { this.#dispatch( cell.isElement() ? E.ELEMENT_REMOVE : E.LINK_REMOVE, cell ); } }, "change:position": ( element, position, opt = {} ) => { const { keyboard = false } = opt; this.#dispatch( E.ELEMENT_POSITION_CHANGE, element, position, keyboard ); }, "change:source": ( link, source ) => { this.#dispatch( E.LINK_SOURCE_CHANGE, link, source ); }, "change:target": ( link, target ) => { this.#dispatch( E.LINK_TARGET_CHANGE, link, target ); }, "change:vertices": ( link, vertices ) => { this.#dispatch( E.LINK_VERTICES_CHANGE, link, vertices ); }, // Note: remove:vertex fires only when the vertex was removed by the user (or using removeVertex call) // and not when simplifying the route with removeRedundantLinearVertices (opt.redundancyRemoval on Vertices // and Segment tool). "remove:vertex": ( link, vertex, index ) => { this.#dispatch( E.LINK_VERTEX_REMOVE, link, vertex, index ); }, "change:labels": ( link, labels, info ) => { if ( "designation" in link.changed ) { this.#dispatch( E.LINK_DESIGNATION_CHANGE, link, link.changed.designation ); } else { const hasDesignation = !!( link.designation() ); if ( hasDesignation ) { // filter designation label out // NOTE: first element (=designation label) unused! [, ...labels] = labels; } if ( labels.length ) { // will fire even when label is changed const { propertyPath, propertyValue, propertyPathArray, rewrite = false } = info; const keys = Object.keys( propertyValue ); const index = propertyPathArray[1] - ( hasDesignation ? 1 : 0 ); // text has changed: if ( propertyPath.includes( Link.getLabelTextPath() ) ) { this.#dispatch( E.LINK_LABEL_CHANGE, link, labels, labels[index], "text", propertyValue ); } // position has changed: else if ( keys.length === 1 && keys[0] === "position" ) { this.#dispatch( E.LINK_LABEL_CHANGE, link, labels, labels[index], "position", propertyValue.position ); } // created or recreated label: else { this.#dispatch( E.LINK_LABEL_ADD, link, labels, labels[index], rewrite ); } } else { this.#dispatch( E.LINK_LABEL_REMOVE, link, labels ); } } }, "change:z": ( cell, z, opt ) => { // `poppedUpContainer` and `revertedContainer` in `opt` need to be sent out this.#dispatch( E.CELL_Z_INDEX_CHANGE, cell, z, opt ); }, "change:expanded": function ( element, expanded ) { this.#dispatch( expanded ? E.CONTAINER_EXPAND : E.CONTAINER_COLLAPSE, element ); }, "change:embeds": function ( parent, childrenIds ) { this.#dispatch( graphUtils.isSubcontainer( parent ) ? E.SUBCONTAINER_EMBEDS_CHANGE : E.CONTAINER_EMBEDS_CHANGE, parent, childrenIds.map( id => this.#getCell( id ) ) ); }, "change:parent": function ( model, parentId ) { const parentEl = this.#getElement( parentId ); this.#dispatch( E.CELL_PARENT_CHANGE, model, parentEl ); }, [E.ELEMENT_RESTRICT_POSITION_TO_PARENT]: function ( model, endPosition, deltas ) { this.#dispatch( E.ELEMENT_RESTRICT_POSITION_TO_PARENT, model, endPosition, deltas ); }, [E.SUBCONTAINERS_SWAP]: function ( { subcontainer, sibling, direction } ) { this.#dispatch( E.SUBCONTAINERS_SWAP, subcontainer, sibling, direction ); } } ); this.listenTo( this.#paper, { "cell:pointerdown": ( view, e, x, y ) => { const { model } = view; this.#dispatch( model.isElement() ? E.ELEMENT_POINTERDOWN : E.LINK_POINTERDOWN, view, { x, y }, e ); }, "cell:pointerup": ( view, e, x, y ) => { const { model } = view; this.#dispatch( model.isElement() ? E.ELEMENT_POINTERUP : E.LINK_POINTERUP, view, { x, y }, e ); }, "cell:menubutton:pointerdown": function ( view, tool, e ) { const evName = E[view.model.isElement() ? "ELEMENT_MENUBUTTON_POINTERDOWN" : "LINK_MENUBUTTON_POINTERDOWN"]; this.#dispatch( evName, view, tool, e ); }, "cell:menubutton:keyboardtrigger": function ( view, tool, e ) { const evName = E[view.model.isElement() ? "ELEMENT_MENUBUTTON_KEYBOARDTRIGGER" : "LINK_MENUBUTTON_KEYBOARDTRIGGER"]; this.#dispatch( evName, view, tool, e ); }, "element:connectbutton:pointerup": function ( view, tool, position, coords, e ) { this.#dispatch( E.ELEMENT_CONNECTBUTTON_POINTERUP, view, tool, position, coords, e ); }, "element:connectbutton:keyboardtrigger": function ( view, tool, position, coords, e ) { this.#dispatch( E.ELEMENT_CONNECTBUTTON_KEYBOARDTRIGGER, view, tool, position, coords, e ); }, "link:mouseenter": ( view, e ) => { const point = this.clientToLocalPoint( e.clientX, e.clientY ); this.#dispatch( E.LINK_MOUSEENTER, view, point, e ); }, "link:mouseleave": ( view, e ) => { const point = this.clientToLocalPoint( e.clientX, e.clientY ); this.#dispatch( E.LINK_MOUSELEAVE, view, point, e ); }, "link:addelement:pointerdown": function ( view, tool, point, e ) { this.#dispatch( E.LINK_ADDELEMENTBUTTON_POINTERDOWN, view, tool, point, e ); }, "link:addelement:pointerup": function ( view, tool, point, e ) { this.#dispatch( E.LINK_ADDELEMENTBUTTON_POINTERUP, view, tool, point, e ); }, "link:addelement:keyboardtrigger": function ( view, tool, point, e ) { this.#dispatch( E.LINK_ADDELEMENTBUTTON_KEYBOARDTRIGGER, view, tool, point, e ); }, "link:placeholder:pointerdown": ( view, tool, ratio, point, e ) => { point = { ...this.toGridSize( point ) }; if ( ratio === 0 ) { this.#dispatch( E.LINK_SOURCE_PLACEHOLDER_POINTERDOWN, view, tool, ratio, point, e ); } else if ( ratio === 1 ) { this.#dispatch( E.LINK_TARGET_PLACEHOLDER_POINTERDOWN, view, tool, ratio, point, e ); } this.#dispatch( E.LINK_PLACEHOLDER_POINTERDOWN, view, tool, ratio, point, e ); }, "link:placeholder:pointerup": ( view, tool, ratio, point, e ) => { point = { ...this.toGridSize( point ) }; if ( ratio === 0 ) { this.#dispatch( E.LINK_SOURCE_PLACEHOLDER_POINTERUP, view, tool, ratio, point, e ); } else if ( ratio === 1 ) { this.#dispatch( E.LINK_TARGET_PLACEHOLDER_POINTERUP, view, tool, ratio, point, e ); } this.#dispatch( E.LINK_PLACEHOLDER_POINTERUP, view, tool, ratio, point, e ); }, "link:placeholder:keyboardtrigger": ( view, tool, ratio, point, e ) => { point = { ...this.toGridSize( point ) }; if ( ratio === 0 ) { this.#dispatch( E.LINK_SOURCE_PLACEHOLDER_KEYBOARDTRIGGER, view, tool, ratio, point, e ); } else if ( ratio === 1 ) { this.#dispatch( E.LINK_TARGET_PLACEHOLDER_KEYBOARDTRIGGER, view, tool, ratio, point, e ); } this.#dispatch( E.LINK_PLACEHOLDER_KEYBOARDTRIGGER, view, tool, ratio, point, e ); }, "link:anchor:pointerup": ( view, tool, end, anchor, e ) => { const evName = E[end === "source" ? "LINK_SOURCE_ANCHOR_POINTERUP" : "LINK_TARGET_ANCHOR_POINTERUP"]; this.#dispatch( evName, view, tool, anchor, e ); }, "link:vertexhandle:pointerup": ( view, tool, handle, vertexNewCoords, vertexOldCoords, e ) => { this.#dispatch( E.LINK_VERTEXHANDLE_POINTERUP, view, tool, handle, vertexNewCoords, vertexOldCoords, e ); }, "link:segmenthandle:pointerup": ( view, tool, handle, newChangedValues, oldChangedValues, e ) => { this.#dispatch( E.LINK_SEGMENTHANDLE_POINTERUP, view, tool, handle, newChangedValues, oldChangedValues, e ); }, "blank:pointerdown": ( e, x, y ) => { this.#dispatch( E.BLANK_POINTERDOWN, { x, y }, e ); }, "blank:pointerup": ( e, x, y ) => { this.#dispatch( E.BLANK_POINTERUP, { x, y }, e ); }, "blank:contextmenu": ( e, x, y ) => { this.#dispatch( E.BLANK_CONTEXT_MENU, { x, y }, e ); }, "freetransform:resizestart": ( view, e ) => { const { model } = view; const { width, height } = model.size(); this.#dispatch( E.SUBCONTAINER_FREETRANSFORM_RESIZE_START, model, width, height, e ); }, "freetransform:resizestop": ( view, e ) => { const { model } = view; const { width, height } = model.size(); this.#dispatch( E.SUBCONTAINER_FREETRANSFORM_RESIZE_STOP, model, width, height, e ); } } ); this.listenTo( this.#selection, { "change": ( selection, det ) => { this.#dispatch( DiagramBuilderEvent.SELECTION_CHANGE, selection.collection.models, det ); } } ); this.listenTo( this.#stencil, { "element:beforedrop": ( draggedView, endModel, e, x, y, droppedOverView, droppedOverTool ) => { this.#dispatch( E.ELEMENT_BEFORE_DROP, draggedView, endModel, { x, y }, droppedOverView, droppedOverTool, e ); }, "element:drop": ( view, e, x, y ) => { this.#dispatch( E.ELEMENT_DROP, view, { x, y }, e ); } } ); if ( this.#keyboardController ) { this.listenTo( this.#keyboardController, { "beforeaction": ( keyboardController, action, e, processObj ) => { this.#dispatch( E.BEFORE_KEYBOARD_ACTION, action, e, processObj ); } } ); } } #keyboardUsageChange( controller, usingKeyboard ) { this.#viewController.useKeyboard( usingKeyboard ); } #dispatch( evName, ...rest ) { if ( !evName || typeof evName != "string" ) { throw new Error( "Event name must be specified." ); } if ( !this.isEventSuspended( evName ) ) { this.trigger( evName, ...rest ); } } #initEls( el ) { const cfg = this.#config; const { readOnly } = cfg; const { paperCls, stencilCls, navigatorCls, legacyCls } = cfg.style; this.#el = typeof el == "string" ? document.querySelector( el ) : el; if ( !this.#el ) { throw new Error( "Diagram Builder: Render-to HTML element not found. Fix it by setting the \"el\" config to an existing element or its selector." ); } if ( !this.#el.getAttribute( "tabindex" ) ) { this.#el.setAttribute( "tabindex", 0 ); } if ( legacyCls?.length && isSafari() ) { this.#el.classList.add( ...( Array.isArray( legacyCls ) ? legacyCls : legacyCls.split( " " ) ) ); } if ( readOnly ) { this.#el.classList.add( "readonly" ); } // paper let paperEl = typeof cfg.paperEl == "string" ? this.#el.querySelector( cfg.paperEl ) : cfg.paperEl; if ( !paperEl ) { paperEl = document.createElement( "div" ); this.#el.appendChild( paperEl ); } if ( paperCls?.length ) { paperEl.classList.add( ...( Array.isArray( paperCls ) ? paperCls : paperCls.split( " " ) ) ); } this.#paperEl = paperEl; // stencil const { renderStencil } = cfg; if ( renderStencil ) { let stencilEl = typeof cfg.stencilEl == "string" ? this.#el.querySelector( cfg.stencilEl ) : cfg.stencilEl; if ( !stencilEl ) { stencilEl = document.createElement( "div" ); this.#el.appendChild( stencilEl ); } if ( stencilCls?.length ) { stencilEl.classList.add( ...( Array.isArray( stencilCls ) ? stencilCls : stencilCls.split( " " ) ) ); } this.#stencilEl = stencilEl; } // navigator let navEl = typeof cfg.navigatorEl == "string" ? this.#el.querySelector( cfg.navigatorEl ) : cfg.navigatorEl; if ( !navEl ) { navEl = document.createElement( "div" ); this.#el.appendChild( navEl ); } if ( navigatorCls?.length ) { navEl.classList.add( ...( Array.isArray( navigatorCls ) ? navigatorCls : navigatorCls.split( " " ) ) ); } this.#navigatorEl = navEl; // capture phase is necessary this.#el.addEventListener( "mousedown", this.#setFocused, true ); this.#el.addEventListener( "touchstart", this.#setFocused, true ); this.onFocus = this.onFocus.bind( this ); this.onBlur = this.onBlur.bind( this ); this.#el.addEventListener( "focus", this.onFocus ); this.#el.addEventListener( "blur", this.onBlur ); } #setFocused() { // NOTE: this = e.currentTarget = app dom el this.focus( { preventScroll: true } ); } #initGraph() { this.#graph = new Graph( {}, { cellNamespace: shapes } ); } #initPaper() { const { drawGrid, initialZoom, paperWidth, paperHeight, requireLinkSourceElement, requireLinkTargetElement, style, defaultRouter } = this.#config; const graph = this.#graph; const paper = new Paper( { async: true, gridSize: 10, drawGrid: drawGrid ? { name: "mesh", args: { color: drawGrid === true ? "var(--a-diagram-grid-color, #eee)" : drawGrid } } : false, width: paperWidth, height: paperHeight, sorting: Paper.sorting.APPROX, // NOTE: Do NOT use EXACT, it bugs out with async paper linkPinning: true, model: graph, cellViewNamespace: shapes, connectionStrategy: function ( end, view, magnet, coords, linkModel, endString, paper, side ) { const { model } = view; const distanceFromPoint = 10; let dx = 0, dy = 0; if ( graphUtils.isContainer( model ) ) { if ( side ) { switch ( side ) { case "top": dx = ShapeElement.DEFAULT_WIDTH / 2; dy = distanceFromPoint; break; case "bottom": dx = ShapeElement.DEFAULT_WIDTH / 2; dy = ShapeElement.DEFAULT_HEIGHT - distanceFromPoint; break; case "right": dx = ShapeElement.DEFAULT_WIDTH - distanceFromPoint; dy = ShapeElement.DEFAULT_HEIGHT / 2; break; case "left": dx = distanceFromPoint; dy = ShapeElement.DEFAULT_HEIGHT / 2; } end.anchor = { name: "topLeft", args: { dx, dy } }; } else { connectionStrategies.pinAbsolute( end, view, magnet, coords ); } } else { if ( side ) { switch ( side ) { case "top": dy = distanceFromPoint; break; case "bottom": dy = -distanceFromPoint; break; case "right": dx = -distanceFromPoint; break; case "left": dx = distanceFromPoint; } end.anchor = { name: side, args: { dx, dy } }; } else { connectionStrategies.pinRelative( end, view, magnet, coords ); } } }, defaultLink: new SingleLink( { requireSourceElement: !!requireLinkSourceElement, requireTargetElement: !!requireLinkTargetElement, cls: style.linkCls, labelCls: style.linkLabelCls } ), defaultRouter: getRouterConfig( defaultRouter ), defaultConnector: { name: "rounded" }, defaultConnectionPoint: createConnectionPoint(), defaultAnchor: ( endView, endMagnet, anchorReference, args ) => { const { model } = endView; if ( graphUtils.isContainer( model ) ) { return joint.anchors.topLeft( endView, endMagnet, anchorReference, { dx: ShapeElement.DEFAULT_WIDTH / 2, dy: ShapeElement.DEFAULT_HEIGHT / 2 } ); } return joint.anchors.center( endView, endMagnet, anchorReference, args ); }, defaultLinkAnchor: { name: "connectionClosest" }, snapLabels: true, interactive: ( view ) => { const { model } = view; const cellReadOnly = model.isReadOnly(); const ro = this.isReadOnly() || cellReadOnly; let elementMove = !ro; if ( graphUtils.isSubcontainer( model ) || graphUtils.isContainer( model ) && model.isExpanded() || model.isDisabled() // this disables movement on first pointer down when there is an expanded container ) { elementMove = false; } const options = { elementMove, linkMove: false, labelMove: !ro, }; return options; }, // The connection validation might be a bit confusing: // 1. First the validateConnection is triggered when a cell is moused-over while dragging a link (source, target). // It fires only above cells! Not paper. // 2. That runs 'canLink' to tell us whether the connection is allowed between the two cells or not. // IMPORTANT: This is used only for the highlighting! // 3. Then, on pointer-up, allowLink runs, which truly determines whether the link will be created. If it is not // allowed, we may still allow placing a (+) placeholder at the location - in that case we must return true // from allowLink. If we don't want the placeholder, we return false from allowLink which reverts the link to // its original position or removing it completely. Thus we cater for special cases (e.g. containers) here - // as validateConnection already returned false for them, they are not highlighted. If no special case, we run // canLink again to tell us whether the connection is allowed. // Fires after arrowhead (not only the tool but the link's el) pointer up allowLink: ( linkView, paper, dragData ) => { const { model: linkModel } = linkView; // If validateConnection returns false, the target will be a point and not a cell that's hovered. // We need to check whether there is a cell at that point, if there is, it is not a valid connection // and we need to revert it to its original position (source/target) const end = linkModel[dragData.arrowhead](); if ( commonUtils.isPoint( end ) ) { const { x, y } = this.#paper.localToClientPoint( end.x, end.y ); const el = document.elementFromPoint( x, y ); const viewUnderPointer = this.#paper.findView( el ); if ( viewUnderPointer ) { const { model: modelUnderPointer } = viewUnderPointer; if ( modelUnderPointer.isElement() ) { // we must be able to create links (with placeholders) over sub-containers, but only in the // link's own embed-parent; // for everything else, return false = no link allowed // also, don't allow link creation over the header of the subcontainer, so return false if ( graphUtils.isSubcontainerView( viewUnderPointer ) ) { const { model: subcontainerModel } = viewUnderPointer; if ( linkModel.getParentCell() !== subcontainerModel ) { return false; } else if ( viewUnderPointer.findNode( "header" ).contains( el ) ) { return false; } // else: proceeds to #canLink a few lines below } else { return false; } } // for links we need to run through the #canLink as we may allow releasing the mouse button // over a link (but not to connect to a link) but only if the other link is not embedded (or vice-versa) else if ( modelUnderPointer.getParentCell() !== linkModel.getParentCell() ) { return false; } // else run #canLink } } return this.#canLink( linkModel.getSourceCell(), linkModel.getTargetCell(), linkView ); }, validateConnection: ( cellViewS, magnetS, cellViewT, magnetT, end, linkView ) => { return this.#canLink( cellViewS?.model, cellViewT?.model, linkView ); }, viewport: view => { if ( view instanceof Selection || view instanceof ui.FreeTransform ) { return true; } // Hide any cell which is embedded inside a collapsed ancestor. return graphUtils.shouldCellRender( view.model ); }, restrictTranslate: view => { const { subcontainerBehavior } = this.#config; // the docs don't mention this, possibly a joint bug - when there is a selection, no elementview is passed! const model = view?.model || this.getSelection()[0]; if ( model ) { const parent = model.getParentCell(); // expanded containers cannot be moved around; // if it is a subcontainer we must prevent the movement as well; // this applies to the keyboard only as the elementMove is off for the subcontainers if ( graphUtils.isContainer( model ) && model.isExpanded() || graphUtils.isSubcontainer( model ) ) { const { x, y } = model.position(); const { width, height } = model.size(); return { x, y, width, height }; } // all the embeds must be limited to the edges of the parent if the behavior is RESTRICT_TRANSLATE else if ( graphUtils.isSubcontainer( parent ) && subcontainerBehavior === SubcontainerBehavior.RESTRICT_TRANSLATE ) { const { x, y, width, height } = parent.getBBox(); const box = { x, y: y + Subcontainer.HEADER_HEIGHT, width, height: height - Subcontainer.HEADER_HEIGHT }; return box; } } return null; } } ); this.#paper = paper; const paperScroller = new PaperScroller( { paper, scrollWhileDragging: true, padding: 30, autoResizePaper: true, allowNewOrigin: "any", baseWidth: 500, baseHeight: 500, contentOptions: () => { // filtered cells - embedded "collapsed" children are excluded const cells = graph.getCells() .filter( cell => !cell.isEmbedded() ? true : cell.getAncestors().at( -1 ).isExpanded() ); const graphBBox = graph.getCellsBBox( cells ); // can be null if no cells! if ( this.isReadOnly() ) { return { contentArea: graphBBox || new g.Rect( 0, 0, 300, 200 ), allowNewOrigin: "any", allowNegativeBottomRight: true, padding: 30, gridWidth: 0, gridHeight: 0 }; } // else: const minArea = new g.Rect( 0, 0, this.#config.paperWidth, this.#config.paperHeight ); // inflate so the tools are never outside of the viewport graphBBox?.inflate( 30 ); const contentArea = minArea.union( graphBBox ); return { contentArea }; } } ); this.#paperEl.appendChild( paperScroller.el ); paperScroller.render(); if ( initialZoom !== 1 && initialZoom !== 0 ) { paperScroller.zoom( initialZoom, { absolute: true } ); } this.#paperScroller = paperScroller; this.#centerContent(); } #centerContent() { // We can't use paperScroller.centerContent( { useModelGeometry: false } ) as that would measure // the collapsed (embedded) items as well. const graph = this.#graph; const allCells = graph.getCells(); const visibleCells = allCells.filter( cell => graphUtils.shouldCellRender( cell ) ); if ( visibleCells.length ) { const visibleCellsBBox = graph.getCellsBBox( visibleCells ); this.#paperScroller.positionRect( visibleCellsBBox, "center" ); } else { this.#paperScroller.center(); } } #canLink( sourceModel, targetModel, linkView ) { const linkModel = linkView.model; const requireSource = linkModel.get( "requireSourceElement" ); const requireTarget = linkModel.get( "requireTargetElement" ); const linkParentCell = linkModel.getParentCell(); // When sourceModel or targetModel is not set, we're linking to a Point and the below // would eval to 'undefined' (i.e. falsy). We need to allow that thus the comparison with bool. const allowSourceLinkOut = sourceModel?.allow( Element.ALLOW_LINK_OUT ) !== false; const allowTargetLinkIn = targetModel?.allow( Element.ALLOW_LINK_IN ) !== false; const sourceParentCell = sourceModel?.getParentCell() || null; const targetParentCell = targetModel?.getParentCell() || null; const cfg = this.#config; const readOnly = cfg.readOnly || ( sourceModel && sourceModel.isReadOnly() ) || ( targetModel && targetModel.isReadOnly() ); const { allowConnection } = cfg; // readonly app or a connection between read-only cell(s) if ( readOnly ) { return false; } // no source el when required if ( requireSource && !( sourceModel instanceof dia.Element ) ) { return false; } // no target el when required if ( requireTarget && !( targetModel instanceof dia.Element ) ) { return false; } // source doesn't allow link-out or target doesn't allow link-in if ( !allowSourceLinkOut || !allowTargetLinkIn ) { return false; } // link to link if ( sourceModel instanceof dia.Link || targetModel instanceof dia.Link ) { return false; } // do not allow linking an embedded el to a point outside the parent cell if ( linkParentCell ) { const linkParentCellBBox = linkParentCell.getBBox(); if ( ( sourceModel && sourceParentCell !== linkParentCell ) || ( targetModel && targetParentCell !== linkParentCell ) || ( !sourceModel && !linkParentCellBBox.containsPoint( linkModel.source() ) ) || ( !targetModel && !linkParentCellBBox.containsPoint( linkModel.target() ) ) ) { return false; } } // do not allow linking an outside element to an embedded if ( ( linkParentCell == null && ( sourceParentCell != null || targetParentCell != null ) ) ) { return false; } // config validation if ( typeof allowConnection == "function" ) { return allowConnection( sourceModel, targetModel, linkView ) !== false; } return true; } #initRouters() { defineCustomRouters(); } // Adhering to jointjs: returning true means filter out #filterSelectedCell( cell, selectionInitiatedOverView ) { const { filterSelection } = this.#config; let filterOut = false; if ( typeof filterSelection === "function" ) { filterOut = filterSelection( cell, selectionInitiatedOverView, this.#selection ) !== false; } else if ( Array.isArray( filterSelection ) && filterSelection.length ) { filterOut = filterSelection.includes( cell.prop( "typeId" ) ); } return filterOut; } #initSelection() { this.#clipboard = new ui.Clipboard(); this.#selection = new Selection( { useModelGeometry: true, translateConnectedLinks: ui.Selection.ConnectedLinksTranslation.SUBGRAPH, boxContent: false, paper: this.#paper, paperScroller: this.#paperScroller, allowCellInteraction: true, frames: new ui.HighlighterSelectionFrameList( { highlighter: highlighters.mask, options: { padding: 1, className: "selection-highlight", deep: true, layer: "front", selector: "root", attrs: { "stroke": "var(--a-diagram-cell-selection, orange)", "stroke-width": 2, } } } ), // applies only to selection with lasso (not individual clicks - those need to be // sorted in the selection controller) // return false to select, true to ignore filter: ( cell, selectionInitiatedOverView ) => { const initiatorModel = selectionInitiatedOverView?.model; // if selection begins over a subcontainer, select only the contained elements if ( graphUtils.isSubcontainer( initiatorModel ) ) { // contained elements if ( cell.getParentCell() === initiatorModel ) { return cell.isSelectable() ? this.#filterSelectedCell( cell, selectionInitiatedOverView ) : true; } // the subcontainer should not be selected in this case else { return true; } } // selection begins on the paper: // 1) automatically exclude all subcontainers and subcontainers' children if ( graphUtils.isSubcontainer( cell ) || cell.getParentCell() ) { return true; } // 2) but check every other cell's selectability return cell.isSelectable() ? this.#filterSelectedCell( cell, selectionInitiatedOverView ) : true; }, allowTranslate: ( selection ) => { const { collection } = selection; const disallowTranslate = collection.models.some( model => { return model.isElement() && model.isReadOnly() || model.isLink(); } ); return !disallowTranslate; } } ); } #initKeyboard() { this.#keyboard = new ui.Keyboard( { filter: ( e ) => { const { target } = e; return this.#el.contains( target ); } } ); } #initTooltips() { new ui.Tooltip( { rootTarget: document.body, target: "[data-tooltip]", padding: 10, animation: { delay: "600ms", duration: "200ms" }, template: "
", direction: "auto" } ); } #initStencil() { const { renderStencil: render, rtl } = this.#config; let elementIds = this.#config.stencilElements; const gap = 5; const itemWidth = 30; const itemHeight = 30; const items = []; if ( rtl ) { elementIds = [...elementIds].reverse(); } elementIds.forEach( ( id ) => { const mEl = this.#elementMap.find( el => { return el.typeId === id; } ); if ( mEl ) { const el = mEl.stencilElement.clone(); el.resize( 30, 30 ); items.push( el ); } } ); const itemCount = items.length; const stencilWidth = itemWidth * itemCount + gap * ( itemCount - 1 ) + 2 * gap; const stencilHeight = itemHeight + 2 * gap; const stencil = new Stencil( { graph: this.#graph, paper: this.#paperScroller, width: stencilWidth, height: stencilHeight, usePaperGrid: true, dropAnimation: true, layout: { columnWidth: itemWidth, columns: itemCount, rowHeight: itemHeight, resizeToFit: true, columnGap: gap, marginX: gap, marginY: gap }, snaplines: this.#snaplines, canDrag: () => { return !this.#config.readOnly; }, dragStartClone: el => { const typeId = el.prop( "typeId" ); const item = this.#elementMap.find( el => el.typeId === typeId ); return item.element.clone(); }, paperOptions: { background: { color: "#eee" } } // The 'validateDrop' custom prop is added in the viewController } ); this.#stencil = stencil; // We always need to render the stencil because otherwise we can't use // external drag & drop... as it relies on it. const stencilV = stencil.render(); if ( render ) { this.#stencilEl.style.width = `${stencilWidth}px`; this.#stencilEl.style.height = `${stencilHeight}px`; if ( this.#config.readOnly ) { this.#hideStencil(); } this.#stencilEl.appendChild( stencilV.el ); stencil.load( items ); } } #hideStencil() { if ( this.#stencilEl ) { this.#stencilEl.style.visibility = "hidden"; } } #showStencil() { if ( this.#stencilEl ) { this.#stencilEl.style.visibility = "visible"; } } #initSnaplines() { this.#snaplines = new Snaplines( { paper: this.#paper, usePaperGrid: true, // Setting distance to 5 pixel because the default is 10 -> meaning that a 70px el would show // a "center-snap" to a 60px element which obviously wouldn't be aligned because of a 10px grid size. // Setting it to small number, e.g. to 1 will cause difficult alignment as the drag element will need // to be very close (1px) to the point. distance: 5, // `filter:` moved to viewcontroller } ); } #initNavigator() { const nav = new ui.Navigator( { paperScroller: this.#paperScroller, width: this.#config.navigatorWidth, height: this.#config.navigatorHeight, zoom: false, // needs to be set explicitly to false padding: 0, paperOptions: { async: true, cellViewNamespace: shapes, sorting: Paper.sorting.APPROX, background: { color: "#fff" }, defaultRouter: getRouterConfig( this.#config.defaultRouter ), defaultConnector: { name: "rounded" }, defaultConnectionPoint: createConnectionPoint(), viewport: view => graphUtils.shouldCellRender( view.model ) } } ); this.#navigator = nav; this.#navigatorEl.appendChild( nav.el ); nav.render(); if ( this.#config.showNavigator ) { this.#setNavigatorVisibility( true ); } else { this.#setNavigatorVisibility( false ); } } #registerElementMap( elements ) { let elementCls = this.#config.style.elementCls || []; // if it is empty string, return an empty array elementCls = Array.isArray( elementCls ) ? elementCls : elementCls.split( " " ); const map = elements.map( cfg => { const { type, typeId, tooltip, stencilTooltip, glyph = "", glyphColor, decorationPattern, } = cfg; let { cls = "" } = cfg; const optionalAttrs = util.merge( {}, this.#ELEMENT_TYPES_OPTIONAL_ATTRIBUTES ); const ns = type.split( "." ); const elementConfig = { filters: this.#elementFilters, rtl: this.#config.rtl }; const stencilElementConfig = {}; // FUTURE_IMPROVEMENT: add for the stencil shapes as well - not urgent, we are not using the jointjs stencil cls = cls || []; elementConfig.cls = [...elementCls, ...( Array.isArray( cls ) ? cls : cls.split( " " ) )]; const element = new ( ns.reduce( ( o, i ) => o[i], shapes ) )( elementConfig ); element.prop( "typeId", typeId ); // creating a copy, if there is no 'small'/stencil version, we will use the original ns const nsStencil = [...ns]; nsStencil[nsStencil.length - 1] = `${nsStencil[nsStencil.length - 1]}Small`; // get the stencil-element constructor based on whether there is or there isn't a small version let Ctor = nsStencil.reduce( ( o, i ) => o[i], shapes ); if ( !Ctor ) { Ctor = ns.reduce( ( o, i ) => o[i], shapes ); } const stencilElement = new Ctor( stencilElementConfig ); stencilElement.prop( "typeId", typeId ); // FUTURE_IMPROVEMENT: add for the stencil shapes as well - not urgent, we are not using the jointjs stencil if ( decorationPattern ) { const patternId = this.#definePattern( decorationPattern ); element.prop( "decorationPattern", decorationPattern ); element.decorationPattern( `url(#${patternId})` ); } if ( tooltip ) { if ( typeof element == "string" ) { element.attr( "root", { dataTooltip: tooltip, dataTooltipPosition: "top" } ); } else { element.attr( "root", { dataTooltip: tooltip.text || "", dataTooltipPosition: tooltip.position || "top" } ); } } if ( stencilTooltip ) { if ( typeof stencilTooltip == "string" ) { stencilElement.attr( "root", { dataTooltip: stencilTooltip, dataTooltipPosition: "top" } ); } else { stencilElement.attr( "root", { dataTooltip: stencilTooltip.text || "", dataTooltipPosition: stencilTooltip.position || "top" } ); } } element.glyph( glyph ); stencilElement.glyph( glyph ); if ( glyphColor ) { element.glyphColor( glyphColor ); stencilElement.glyphColor( glyphColor ); } // At the very end, add the optional Object.entries( optionalAttrs ).forEach( ( [key, value] ) => { // if it is already set on the element, we must have set it above if ( element.prop( key ) !== undefined ) { return; } // otherwise use the one set on the cfg or use the default element.prop( key, key in cfg ? cfg[key] : value ); } ); return { typeId, element, stencilElement, cfg }; } ); this.#elementMap = map; } #createElement( typeId, cfg = {} ) { const { text = "", x = 0, y = 0, ...rest } = cfg; const item = this.#elementMap.find( el => el.typeId === typeId ); if ( item ) { const newEl = item.element.clone(); newEl.text( text ); for ( let prop in rest ) { if ( Object.hasOwn( rest, prop ) ) { newEl.prop( prop, rest[prop] ); } } newEl.position( x, y ); return newEl; } } get graph() { return this.#graph; } get paper() { return this.#paper; } get paperScroller() { return this.#paperScroller; } get selection() { return this.#selection; } get stencil() { return this.#stencil; } get snaplines() { return this.#snaplines; } get navigator() { return this.#navigator; } onFocus() { let cls = this.#config.style.focusCls; this.#focused = true; if ( cls?.length ) { this.#el.classList.add( ...( Array.isArray( cls ) ? cls : cls.split( " " ) ) ); } } onBlur() { let cls = this.#config.style.focusCls; this.#focused = false; if ( cls?.length ) { this.#el.classList.remove( ...( Array.isArray( cls ) ? cls : cls.split( " " ) ) ); } } getShapes() { return shapes; } getVisibleArea() { return this.#paperScroller.getVisibleArea(); } getVisibleAreaCenter() { const { x, y, width, height } = this.#paperScroller.getVisibleArea(); const centerX = x + width / 2; const centerY = y + height / 2; return { x: centerX, y: centerY }; } isElementVisible( el, strict = false ) { el = this.#getElement( el ); return this.#paperScroller.isElementVisible( el, { strict } ); } toGridSize( n ) { const gridSize = this.#paper.options.gridSize || 1; if ( arguments.length === 2 ) { return g.Point( arguments[0], arguments[1] ).snapToGrid( gridSize ); } if ( commonUtils.isPoint( n ) ) { return g.Point( n.x, n.y ).snapToGrid( gridSize ); } return g.snapToGrid( n, gridSize ); } attachElementToLink( el, link, opt = {} ) { const paper = this.#paper; el = this.#getElement( el ); link = this.#getLink( link ); if ( !el || !link ) { return; } const linkView = link.findView( paper ); const { point } = opt; let { ratio } = opt; if ( point ) { this.requireView( linkView ); ratio = linkView.getClosestPointRatio( point ); } diaActions.addElementToLinkAtRatio( el.findView( paper ), linkView, ratio ); } addElement( typeId, opt = {} ) { if ( this.isReadOnly() ) { return; } let { async = false, verticalAlign = "top", horizontalAlign = "left", x, y, highlight = false, addToLink = null, embedInto, ...rest } = opt; const graph = this.#graph; const el = this.#createElement( typeId, { x, y, ...rest } ); const { x: centerX, y: centerY } = this.getVisibleAreaCenter(); const parentEl = this.#getElement( embedInto ); if ( !el || ( embedInto != null && !parentEl ) ) { return; } const { width, height } = el.size(); if ( addToLink ) { graph.addCell( el, { async, ...( parentEl && { parentCell: parentEl } ) } ); this.attachElementToLink( el, addToLink.link, { point: addToLink.point, ratio: addToLink.ratio } ); } else { // we expect both to be provided, otherwise we will just set both if ( x == null || y == null ) { if ( parentEl ) { const { width: parentWidth, height: parentHeight, x: parentX, y: parentY } = parentEl.getBBox(); ( { x, y } = this.toGridSize( parentX + ( parentWidth - width ) / 2, parentY + ( parentHeight - height ) / 2 ) ); } else { ( { x, y } = this.toGridSize( centerX - width / 2, centerY - height / 2 ) ); } } else { switch ( horizontalAlign ) { case "center": x = x - width / 2; break; case "right": x = x - width; break; } switch ( verticalAlign ) { case "center": y = y - height / 2; break; case "bottom": y = y - height; break; } ( { x, y } = this.toGridSize( x, y ) ); } // if there is an element at this x, y, move the new element a bit ( { x, y } = graphUtils.getFreePosition( graph, { x, y }, this.#paper.options.gridSize ) ); el.position( x, y ); graph.addCell( el, { async, ...( parentEl && { parentCell: parentEl } ) } ); // If there is parent: no need to call embedCellInto as the view controller handles it. } if ( highlight ) { this.highlight( el, { duration: 2000 } ); } return el; } removeElement( element, opt = {} ) { const { force = false } = opt; // force - remove without checking read-only, etc. rules of the cell const el = this.#getElement( element ); if ( el && !this.isReadOnly() && ( force || diaActions.canRemoveElements( el ) ) ) { el.remove(); } } changeElementType( element, newType ) { const model = this.#getElement( element ); if ( model && !this.isReadOnly() && !model.isReadOnly() && model.allow( Element.ALLOW_TYPE_CHANGE ) ) { const text = model.text(); const { x, y } = model.position(); // copy all the custom properties from the old element to the new one const filterOut = [ "attrs", "decorationPattern", "id", "position", "size", "type", "typeId", "embeds", ...Object.keys( this.#ELEMENT_TYPES_OPTIONAL_ATTRIBUTES ) ]; const extraProps = {}; Object.entries( model.attributes ).forEach( ( [key, value] ) => { if ( !filterOut.includes( key ) ) { extraProps[key] = value; } } ); const parentEl = element.getParentCell(); // create the new element and place it at the same position const newEl = this.#createElement( newType, { text, x, y, ...extraProps } ); this.#freeze(); diaActions.changeElementInPlace( this.#graph, model, newEl ); if ( parentEl ) { diaActions.embedCellInto( newEl, parentEl ); } this.#unfreeze(); } } #freeze() { this.#paper.freeze(); if ( this.#navigatorVisible ) { this.#navigator.targetPaper.freeze(); } } #unfreeze() { this.#paper.unfreeze(); if ( this.#navigatorVisible ) { this.#navigator.targetPaper.unfreeze(); } } #getElement( element ) { return graphUtils.getElement( this.#graph, element ); } #getLink( link ) { return graphUtils.getLink( this.#graph, link ); } #getCell( cell ) { return graphUtils.getCell( this.#graph, cell ); } #setNavigatorVisibility( b ) { this.#navigatorEl.style.display = b ? "block" : "none"; this.#navigator[b ? "unfreeze" : "freeze"](); this.#navigatorVisible = b; // we will trigger a custom event so the viewcontroller can react to it this.#navigator.trigger( "visibility", b ? "visible" : "hidden" ); } #zoomOutToFit() { const { minZoom } = this.#config; this.#paperScroller.zoomToFit( { minScale: minZoom, maxScale: 1, padding: 15 } ); } #afterFromJSON( data ) { const { initialZoom } = this.#config; const readOnly = this.isReadOnly(); const hasRoute = readOnly && data && Object.hasOwn( data, "route" ); const fit = initialZoom === 0; if ( hasRoute ) { this.highlightRoute( data.route, fit ); // if the route is invalid, there won't be a higlighted route if ( !this.#highlightedRoute ) { fit && this.#zoomOutToFit(); this.#centerContent(); } // if there is a route but it shouldn't be scrolled to/zoomed onto, still center else if ( !fit ) { this.#centerContent(); } } else { fit && this.#zoomOutToFit(); this.#centerContent(); } } addLink( source, target, opt = {} ) { const { highlight = false, async = false, embedInto, restrictToParentBBox = true, ...rest } = opt; if ( this.#config.readOnly ) { return; } const link = this.#paper.getDefaultLink(); const sourceIsPoint = commonUtils.isPoint( source ); const targetIsPoint = commonUtils.isPoint( target ); const sourceEl = !sourceIsPoint ? this.#getElement( source ) : null; const targetEl = !targetIsPoint ? this.#getElement( target ) : null; const parentEl = this.#getElement( embedInto ); const { requireLinkSourceElement = false, requireLinkTargetElement = false } = this.#config; for ( let prop in rest ) { if ( Object.hasOwn( rest, prop ) ) { link.prop( prop, rest[prop] ); } } if ( ( !sourceIsPoint && !sourceEl ) || ( !targetIsPoint && !targetEl ) || ( requireLinkSourceElement && sourceIsPoint ) || ( requireLinkTargetElement && targetIsPoint ) || ( embedInto != null && !parentEl ) || ( parentEl && ( ( sourceEl && sourceEl.getParentCell() !== parentEl ) || ( targetEl && targetEl.getParentCell() !== parentEl ) ) ) ) { return; } link.source( sourceIsPoint ? source : sourceEl ); link.target( targetIsPoint ? target : targetEl ); // The view controller automatically embeds the links when either target and/or source are embedded; // That's also the reason we don't have to call `embedCellInto` here. const willGetEmbedded = !!parentEl || targetEl?.isEmbedded() || sourceEl?.isEmbedded(); this.#graph.addCell( link, { async, restrictToParentBBox: willGetEmbedded ? restrictToParentBBox : false, ...( parentEl && { parentCell: parentEl } ) } ); if ( highlight ) { this.highlight( link, { duration: 2000 } ); } return link; } removeLink( link, opt = {} ) { const { force = false } = opt; // force - remove without checking read-only, etc. rules of the cell link = this.#getLink( link ); if ( link && !this.isReadOnly() && ( force || diaActions.canRemoveLinks( link ) ) ) { link.remove(); } } setLinkDesignation( link, designation ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() && link.allow( Link.ALLOW_DESGINATION_CHANGE ) ) { link.designation( designation ); } } setLinkTarget( link, target ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() && link.allow( Link.ALLOW_TARGET_CHANGE ) ) { const isPoint = commonUtils.isPoint( target ); const element = this.#getElement( target ); if ( link && ( isPoint || element ) ) { link.target( element || target ); } } } getLinkTarget( link ) { link = this.#getLink( link ); return link?.target(); } setLinkTargetAnchor( link, anchor ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() ) { const target = link.target(); if ( target ) { const o = { ...target }; o.anchor = anchor; link.target( o ); } } } getLinkTargetAnchor( link ) { link = this.#getLink( link ); const target = link?.target(); return target?.anchor || null; } setLinkSource( link, source ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() ) { const isPoint = commonUtils.isPoint( source ); const element = this.#getElement( source ); if ( link && ( isPoint || element ) ) { // we need to pass the param when not a string as it may contain the anchor link.source( typeof source === "string" ? element : source ); } } } getLinkSource( link ) { link = this.#getLink( link ); return link?.source(); } setLinkSourceAnchor( link, anchor ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() ) { const source = link.source(); if ( source ) { const o = { ...source }; o.anchor = anchor; link.source( o ); } } } getLinkSourceAnchor( link ) { link = this.#getLink( link ); const source = link?.source(); return source?.anchor || null; } setLinkVertices( link, vertices = [] ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() ) { link.vertices( vertices ); } } getLinkVertices( link ) { link = this.#getLink( link ); return link?.vertices(); } highlight( what, opt ) { if ( what instanceof dia.CellView ) { return highlightActions.highlightView( what, opt ); } else { return highlightActions.highlightModel( this.#paper, what, opt ); } } unhighlight( what, highlightId ) { if ( what instanceof dia.CellView ) { highlightActions.unhighlightView( what, highlightId ); } else { highlightActions.unhighlightModel( this.#paper, what, highlightId ); } } setLinkLabel( link, text, opt = {} ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() && link.allow( Link.ALLOW_LABEL_CHANGE ) ) { // there is currently no reason to set the cls from outside, so we will // set only the linkLabelCls opt.cls = this.#config.style.linkLabelCls; diaActions.label( this.#graph, link, text, opt ); } } removeLinkLabel( link ) { link = this.#getLink( link ); if ( link && !this.isReadOnly() && !link.isReadOnly() && link.allow( Link.ALLOW_LABEL_CHANGE ) ) { diaActions.removeLabel( this.#graph, link ); } } elementAt( index ) { return this.#graph.getElements()[index] || null; } linkAt( index ) { return this.#graph.getLinks()[index] || null; } load( data = {}, options = {} ) { this.deselect(); this.unhighlightRoute(); const { cells: dataCells = [] } = data; const { centerContent = true } = options; const cells = []; const embeds = {}; const containerIds = []; const subcontainerCfgs = {}; // { parentId: [subcontainerCfg, subcontainerCfg, ...], parentId: ... } dataCells.forEach( cell => { const { typeId, subcontainer } = cell; const cfg = {}; let embedsArr; // If it has a typeId, it is an element - except subcontainers! Subcontainers do not // have typeId and are not in the #elementMap. The object contains `subcontainer: true`. // FUTURE_IMPROVEMENT: should we change this to something more generic? e.g. "type: element?" if ( subcontainer ) { embedsArr = embeds[cell.parentId] = embeds[cell.parentId] || []; embedsArr.push( cell.id ); cfg.parent = cell.parentId; cfg.rtl = this.#config.rtl; cfg.type = Subcontainer.type; cfg.attrs = { title: { text: cell.text || "" } }; Object.assign( cfg, cell ); delete cfg.subcontainer; delete cfg.parentId; delete cfg.text; delete cfg.position; // We will count the position from the positionIndex const arr = subcontainerCfgs[cfg.parent] || ( subcontainerCfgs[cfg.parent] = [] ); arr.push( cfg ); cells.push( cfg ); } else if ( typeId ) { const def = this.#elementMap.find( def => def.typeId === typeId ); const { text = "", statusIcon, parentId, ...rest } = cell; if ( def ) { const mapEl = def.element; const pattern = mapEl.prop( "decorationPattern" ); const isContainer = graphUtils.isContainer( mapEl ); if ( isContainer ) { containerIds.push( cell.id ); } let patternId; if ( pattern ) { patternId = this.#definePattern( pattern ); } if ( parentId != null ) { embedsArr = embeds[cell.parentId] = embeds[cell.parentId] || []; embedsArr.push( cell.id ); cfg.parent = parentId; cfg.clip = this.#config.subcontainerBehavior === SubcontainerBehavior.CLIP; } cfg.filters = this.#elementFilters; cfg.rtl = this.#config.rtl; cfg.type = mapEl.get( "type" ); // add optional Object.keys( this.#ELEMENT_TYPES_OPTIONAL_ATTRIBUTES ).forEach( key => { // if it is set on the loaded cell, use that prop, otherwise use the map-element cfg[key] = ( key in cell ) ? cell[key] : mapEl.prop( key ); } ); cfg.cls = [...mapEl.get( "cls" ) || []]; cfg.attrs = { label: { text }, glyph: { text: mapEl.glyph() }, statusIcon: { text: statusIcon?.glyph || "", ...( statusIcon?.color && { fill: cell.statusIcon.color } ) }, ...( pattern && { decorationPattern: { fill: `url(#${patternId})` } } ) }; Object.assign( cfg, rest ); // Make sure the containers always start collapsed if ( cfg.type === "apex.Container" ) { cfg.expanded = false; } cells.push( cfg ); } } // No typeId means it must be a link else { const labels = []; const { designation, requireSourceElement = !!this.#config.requireLinkSourceElement, requireTargetElement = !!this.#config.requireLinkTargetElement, labels: cellLabels, parentId, ...rest } = cell; const sourceId = cell.source?.id; const targetId = cell.target?.id; const source = sourceId != null ? dataCells.find( c => c.id === sourceId ) : null; const target = targetId != null ? dataCells.find( c => c.id === targetId ) : null; const sourceParentId = source?.parentId; const targetParentId = target?.parentId; let cls = this.#config.style.linkCls || []; let labelCls = this.#config.style.linkLabelCls || []; let desCls = this.#config.style.linkDesignationCls || []; cfg.type = "apex.SingleLink"; cfg.cls = cls; // Check whether the link needs to be embedded: config contains the parentId. if ( parentId != null ) { embedsArr = embeds[parentId] = embeds[parentId] || []; embedsArr.push( cell.id ); cfg.parent = parentId; cfg.clip = this.#config.subcontainerBehavior === SubcontainerBehavior.CLIP; } // If either source and or target is set, use the parent from there (if any). else if ( sourceParentId != null || targetParentId != null ) { if ( target && source && sourceParentId !== targetParentId ) { throw new Error( `Invalid link configuration received, link (id: ${cell.id}) has different parents of its target and source.` ); } else { const parentId = sourceParentId || targetParentId; embedsArr = embeds[parentId] = embeds[parentId] || []; embedsArr.push( cell.id ); cfg.parent = parentId; } } // else: either doesn't need to be embedded or we cannot determine ("loose" links) // If it is a special 'type' label if ( designation ) { let desName = designation, tooltip, tooltipPosition; if ( typeof designation === "object" ) { ( { typeId: desName, tooltip, tooltipPosition } = designation ); } // find the designation in the app's config const d = this.#linkDesignations.find( cfg => cfg.typeId === desName ); // if we have a cfg, it is allowed if ( d ) { const designationCfg = Link.getLinkDesignationCfg( { distance: 30, glyph: d.glyph, designation: desName, cls: desCls, tooltip, tooltipPosition } ); labels.push( designationCfg ); cfg.designation = designation; } } if ( cellLabels ) { cellLabels.forEach( label => { const labelCfg = Link.getLabelCfg( label.text || "", { ...( label.position && { position: { distance: label.position.distance || .5, offset: label.position.offset || 0 } } ), cls: labelCls } ); labels.push( labelCfg ); } ); } if ( labels.length ) { cfg.labels = labels; } Object.assign( cfg, { requireSourceElement, requireTargetElement, ...rest } ); cells.push( cfg ); } for ( const [ key, value ] of Object.entries( embeds ) ) { const cell = cells.find( cell => cell.id === key ); if ( cell ) { cell.embeds = value; } } } ); // Add the positions of the subcontainers to their configs Object.entries( subcontainerCfgs ).forEach( ( [contId, arr] ) => { const container = cells.find( cell => cell.id === contId ); let prevSiblingPos; let prevSiblingWidth; arr.sort( ( a, b ) => { const aPosIndex = a.positionIndex || 0; const bPosIndex = b.positionIndex || 0; return aPosIndex - bPosIndex; } ); arr.forEach( ( subCfg, index ) => { let pos; if ( index === 0 ) { const { x, y } = container.position; pos = { x: x + Container.CHILDREN_SPACING, y: y + Container.DEFAULT_HEIGHT + Container.CHILDREN_SPACING }; } else { pos = { x: prevSiblingPos.x + prevSiblingWidth + Container.CHILDREN_SPACING, y: prevSiblingPos.y }; } subCfg.position = pos; prevSiblingPos = pos; prevSiblingWidth = subCfg.size.width; } ); } ); containerIds.forEach( cId => { const embeddedCells = embeds[cId].map( eId => { return cells.find( c => c.id === eId ); } ); embeddedCells.sort( ( c1, c2 ) => c1.position.x - c2.position.x ); embeddedCells.forEach( ( c, index ) => { c.positionIndex = index; } ); } ); if ( cells.length ) { this.#graph.fromJSON( { cells } ); if ( centerContent ) { this.#afterFromJSON( data ); } } else { this.clear(); // We need to manually trigger adjustPaper as it won't be called when there are // no cells previously - but only in read-only mode, in editable we want to keep // paper its default size. if ( this.isReadOnly() ) { this.paperScroller.adjustPaper(); } if ( centerContent ) { requestAnimationFrame( () => { this.#centerContent(); } ); } } } clear( silent = false, center = true ) { this.#graph.clear( { silent } ); center && this.#paperScroller.center(); } setElementText( element, text ) { const el = this.#getElement( element ); if ( el && !this.isReadOnly() && !el.isReadOnly() && el.allow( Element.ALLOW_TEXT_CHANGE ) ) { el.text( text ); } } getElementText( element ) { const el = this.#getElement( element ); return el && el.text(); } getElements() { return this.#graph.getElements(); } getElementAt( index ) { return this.#graph.getElements()[index] || null; } getElementById( id ) { return graphUtils.getElementById( this.#graph, id ); } getElementBy( prop, value ) { return graphUtils.getElementBy( this.#graph, prop, value ); } getElementIds() { return this.#graph.getElements().map( el => el.get( "id" ) ); } getLinks() { return this.#graph.getLinks(); } getLinkAt( index ) { return this.#graph.getLinks()[index] || null; } getLinkBy( prop, value ) { return graphUtils.getLinkBy( this.#graph, prop, value ); } getLinkById( id ) { return graphUtils.getLinkById( this.#graph, id ); } select( cells, { add = false, scrollTo = false } = {} ) { cells = Array.isArray( cells ) ? cells : [cells]; cells = graphUtils.getCells( this.#graph, cells ); if ( cells.length ) { selectCells( this.#selection, cells, { add, mode: this.#config.selectionMode, scrollTo, paperScroller: this.#paperScroller } ); } } deselect( cells ) { if ( cells ) { cells = Array.isArray( cells ) ? cells : [cells]; cells = graphUtils.getCells( this.#graph, cells ); } deselectCells( this.#selection, cells ); } getSelection() { return [...this.#selection.collection.models]; } requireView( cell ) { cell = this.#getCell( cell ); if ( cell ) { this.#paper.requireView( cell ); } } zoom( amount, absolute = false ) { if ( !arguments.length ) { return this.getZoom(); } const cfg = this.#config; this.#paperScroller.zoom( amount, { absolute, min: cfg.minZoom, max: cfg.maxZoom } ); } getZoom() { return this.#paperScroller.zoom(); } clientToLocalPoint( p ) { const { x, y } = commonUtils.isPoint( p ) ? p : { x: arguments[0], y: arguments[1] }; return this.#paper.clientToLocalPoint( x, y ); } setReadOnly( value ) { value = !!value; const cfg = this.#config; if ( cfg.readOnly === value ) { return; } cfg.readOnly = value; this.deselect(); // trigger custom event on the graph // the controllers listen to it this.graph.trigger( "readonly", value ); if ( cfg.renderStencil ) { if ( value ) { this.#hideStencil(); } else { this.#showStencil(); } } if ( value ) { this.#el.classList.add( "readonly" ); } else { this.#el.classList.remove( "readonly" ); this.unhighlightRoute(); } } getReadOnly() { return this.#config.readOnly; } isReadOnly() { return this.getReadOnly(); } startDragging( typeId, e ) { const el = this.#createElement( typeId ); if ( !this.isReadOnly() ) { this.#stencil.startDragging( el, e ); } } cancelDrag() { this.#stencil.cancelDrag(); } hitTestElement( el, p ) { el = this.#getElement( el ); if ( !el ) { return; } const bbox = el.getBBox( { rotate: true } ); if ( commonUtils.isPoint( p ) ) { return bbox.containsPoint( p ); } else if ( Array.isArray( p ) ) { return p.filter( p => bbox.containsPoint( p ) ); } return false; } showNavigator() { this.#setNavigatorVisibility( true ); } hideNavigator() { this.#setNavigatorVisibility( false ); } isNavigatorShown() { return this.#navigatorVisible; } toFront( cells, deep = true ) { cells = Array.isArray( cells ) ? cells : [cells]; cells = cells.map( cell => this.#getCell( cell ) ).filter( cell => cell ); if ( cells.length ) { this.#freeze(); diaActions.toFront( cells, { deep } ); this.#unfreeze(); } } toBack( cells, deep = true ) { cells = Array.isArray( cells ) ? cells : [cells]; cells = cells.map( cell => this.#getCell( cell ) ).filter( cell => cell ); if ( cells.length ) { this.#freeze(); diaActions.toBack( cells, { deep } ); this.#unfreeze(); } } drawGrid( color ) { this.#paper.setGrid( { name: "mesh", args: { color } } ); this.#paper.drawGrid(); } setDefaultRouter( routerName, args = {} ) { const paper = this.#paper; const navPaper = this.#navigator.targetPaper; // merge args so we always get the defaults if not set const routerCfg = getRouterConfig( routerName, args ); paper.options.defaultRouter = navPaper.options.defaultRouter = { name: routerCfg.name, args: routerCfg.args }; diaActions.updateAllConnections( paper ); // We can update the navigator only when it is visible, otherwise the nodeCache may // cache wrong bbox sizes after render which will result in link anchors at (0, 0). if ( this.#navigatorVisible ) { diaActions.updateAllConnections( navPaper ); } } getDefaultRouter() { return this.#paper.options.defaultRouter; } setLinkRouter( link, routerName, args = {} ) { link = this.#getLink( link ); if ( link ) { if ( routerName ) { const routerCfg = getRouterConfig( routerName, args ); link.router( { name: routerCfg.name, args: routerCfg.args } ); } else { link.unset( "router" ); } } } getLinkRouter( link ) { link = this.#getLink( link ); if ( link ) { return link.router(); } } scrollIntoView( cell, opt = {} ) { diaActions.scrollIntoView( this.#paperScroller, cell, opt ); } destroy() { this.#el.removeEventListener( "mousedown", this.#setFocused ); this.#el.removeEventListener( "touchstart", this.#setFocused ); this.#el.removeEventListener( "focus", this.onFocus ); this.#el.removeEventListener( "blur", this.onBlur ); // Mvc events are mixed in: this.stopListening(); this.#viewController.destroy(); // ... as no selection mode = no selection controller: if ( this.#selectionController ) { this.#selectionController.destroy(); } this.#keyboardController.destroy(); } getCellZIndex( cell ) { return this.#getCell( cell )?.get( "z" ); } setCellZIndex( cell, index ) { this.#getCell( cell )?.set( "z", index ); } suspendEvents( eventNames ) { eventNames = Array.isArray( eventNames ) ? eventNames : [eventNames]; const existingEvents = Object.values( DiagramBuilderEvent ); eventNames.forEach( name => { if ( existingEvents.includes( name ) && !this.isEventSuspended( name ) ) { this.#suspendedEvents.push( name ); } } ); } resumeEvents( eventNames ) { eventNames = Array.isArray( eventNames ) ? eventNames : [eventNames]; this.#suspendedEvents = this.#suspendedEvents.filter( name => !eventNames.includes( name ) ); } isEventSuspended( eventName ) { return this.#suspendedEvents.includes( eventName ); } focus( opt = {} ) { this.#el.focus( opt ); } hasFocus() { return this.#el.contains( document.activeElement ); } showToast( msg, duration = 3000, closeButton = false ) { uiActions.showToast( msg, { foregroundContainerCls: this.#config.style.toastForegroundContainerCls, target: this.#el, duration, closeButton } ); } getElementConnectedLinks( el, opt = {} ) { let { outbound, inbound } = opt; el = this.#getElement( el ); if ( el ) { if ( !outbound && !inbound ) { outbound = true; inbound = true; } return this.#graph.getConnectedLinks( el, { outbound: !!outbound, inbound: !!inbound } ); } } getElementLoopedLinks( el ) { el = this.#getElement( el ); if ( el ) { return this.getElementConnectedLinks( el, { outbound: true } ).filter( link => { const tc = link.getTargetCell(); const sc = link.getSourceCell(); return sc && tc === sc; } ); } } setElementsConfig( elements = [] ) { this.#registerElementMap( elements ); } highlightRoute( route = [], scrollTo = true, opt = {} ) { const paper = this.#paper; const graph = this.#graph; const paperScroller = this.#paperScroller; const cfg = this.#config; const cells = []; const { zoomAnimation = false } = opt; const navPaper = this.#navigator?.targetPaper; // we will allow route highlighting only in read-only as any change in the graph // would invalidate the route if ( !cfg.readOnly ) { return; } if ( this.#highlightedRoute ) { this.unhighlightRoute(); } // simplify the the highlight config into { key1: { cfg }, key2: { cfg } } so we don't have to array.find in each // iteration const keyedRouteHighlightsCfgs = this.#config.routeHighlights.reduce( ( prev, curr ) => { prev[curr.typeId] = curr; return prev; }, {} ); // the route can be either ids, or objects with id and highlightId or full config containing color, // and/or glyph and/or glyphColor const routeData = route.map( item => { const highlightTypeId = item.highlightTypeId; const highlightCfg = highlightTypeId ? keyedRouteHighlightsCfgs[highlightTypeId] : null; const cell = item instanceof dia.Cell ? item : this.#getCell( typeof item === "string" ? item : item.cellId ); cells.push( cell ); const retObj = { cell, color: item.color ?? highlightCfg?.color ?? "var(--a-diagram-route-default, #808080)", glyph: item.glyph ?? highlightCfg?.glyph, glyphColor: item.glyphColor ?? highlightCfg?.glyphColor, glyphTooltip: item.glyphTooltip ?? highlightCfg?.glyphTooltip, allowHighlightedOutboundLinks: highlightCfg?.allowHighlightedOutboundLinks ?? true }; return retObj; } ).filter( obj => obj.cell ); if ( routeData.length ) { this.#highlightedRoute = routeData.map( obj => obj.cell ); this.#routeHighlightsSave = []; const save = this.#routeHighlightsSave; // There is no need to keep the overwritten highlights (e.g. by multiple passes, cycles, etc.) so in reverse order - // as we need the latest - filter them out. // eslint-disable-next-line no-plusplus for ( let i = routeData.length; i--; ) { const stepObj = routeData[i]; const { cell, color } = stepObj; // if we haven't added the cell yet: if ( !save.find( item => item.cell === cell ) ) { const toSave = { cell, color }; if ( cell.isElement() ) { const prevGlyph = cell.statusIcon(); const prevGlyphColor = prevGlyph ? cell.statusIconColor() : null; const prevGlyphTooltip = cell.statusIconTooltip(); const { glyph: newGlyph = "", glyphColor: newGlyphColor = "#000000", glyphTooltip: newGlyphTooltip = null } = stepObj; if ( newGlyph !== prevGlyph ) { toSave.prevGlyph = prevGlyph; toSave.newGlyph = newGlyph; } if ( newGlyphColor !== prevGlyphColor ) { toSave.prevGlyphColor = prevGlyphColor; toSave.newGlyphColor = newGlyphColor; } if ( newGlyphTooltip !== prevGlyphTooltip ) { toSave.prevGlyphTooltip = prevGlyphTooltip; toSave.newGlyphTooltip = newGlyphTooltip; } } save.push( toSave ); } } save.reverse(); // unnecessary, but easier to comprehend if we reverse it to original order // Go through the highlights and apply them. Drop the unneccessary props (e.g. new glyphs) // as they are not needed for unhighlighting. save.forEach( obj => { const { cell } = obj; if ( cell.isElement() ) { const highlightCfg = { padding: 0, className: "route-highlight", layer: cell.isEmbedded() ? "front" : "back", attrs: { "stroke": obj.color, "stroke-width": 4 } }; obj.highlightId = this.highlight( cell, highlightCfg ); // Highlights won't be shown in the navigator because they are paper-dependent. // We need to target the view or call it with the correct paper: if ( navPaper ) { const cellViewInNavigator = navPaper.findViewByModel( cell ); const nhId = this.highlight( cellViewInNavigator, highlightCfg ); obj.navigatorHighlightId = nhId; } if ( "newGlyph" in obj ) { cell.statusIcon( obj.newGlyph ); delete obj.newGlyph; } if ( "newGlyphColor" in obj ) { cell.statusIconColor( obj.newGlyphColor ); delete obj.newGlyphColor; } if ( "newGlyphTooltip" in obj ) { cell.statusIconTooltip( obj.newGlyphTooltip ); delete obj.newGlyphTooltip; } } else { cell.findView( paper ).addCls( "route-link" ); cell.route( true, obj.color ); } delete obj.color; } ); // The route is highlighted at this point. If it is needed, we can remove the highlight of the outbound // link(s) of a particular element (type), e.g. of links of the nodes whose status is not 'completed'. // This comes from the highlight settings object, i.e. routeHighlights: { typeId, ... , allowHighlightedOutboundLinks: false}. // The default value is true - all outbound links are always highlighted. const highlightedLinkObjs = routeData.filter( obj => obj.cell.isLink() ); highlightedLinkObjs.forEach( obj => { const { cell } = obj; const sourceElement = cell.getSourceElement(); const sourceElementHighlightCfg = routeData.find( obj => obj.cell === sourceElement ); if ( sourceElementHighlightCfg && sourceElementHighlightCfg.allowHighlightedOutboundLinks === false ) { cell.findView( paper ).removeCls( "route-link" ); cell.route( false ); this.#routeHighlightsSave.splice( this.#routeHighlightsSave.findIndex( obj => obj.cell === cell ), 1 ); } } ); // Scroll into the view - try to fit all the elements of the route initially. If not possible, drop them // one by one and center the ones that are closest (flow-wise) to the last node. if ( scrollTo ) { const { width: availableWidth, height: availableHeight } = paperScroller.getClientSize(); const visibleArea = paperScroller.getVisibleArea(); const uniqueEls = unique( this.#highlightedRoute.filter( cell => cell.isElement() ) ) .filter( el => !el.isEmbedded() ? true : el.getAncestors().at( -1 ).isExpanded() ); // filter out not visible els const uniqueElsCount = uniqueEls.length; let contentArea; let contentAreaAtMinZoom; while ( uniqueEls.length ) { contentArea = graph.getCellsBBox( uniqueEls ); contentAreaAtMinZoom = new g.Rect( contentArea ).scale( cfg.minZoom, cfg.minZoom ); // if it is all visible, don't do anything (let's ignore padding to prevent unnecessary movement) if ( visibleArea.containsRect( contentArea ) ) { break; } // if it is not in the view or it currently doesn't fit but it fits at min zoom else if ( availableWidth >= contentAreaAtMinZoom.width && availableHeight >= contentAreaAtMinZoom.height ) { const minScale = cfg.minZoom; // be completely zoomed out when we can't fit it all const maxScale = uniqueEls.length === uniqueElsCount ? paperScroller.zoom() : cfg.minZoom; // zoomToRect seems to be off, looks like it doesn't account for the scrollbars or something similar. // We will inflate the content area a bit so it is not overlapping with the scrollbar. contentArea.inflate( 30 ); paperScroller[zoomAnimation ? "transitionToRect" : "zoomToRect"]( contentArea, { minScale, maxScale, scaleGrid: .1 } ); break; } // remove the first cell and try again else { uniqueEls.shift(); } } } } } unhighlightRoute() { const hl = this.#routeHighlightsSave; const paper = this.#paper; const navPaper = this.#navigator?.targetPaper; if ( hl?.length ) { hl.forEach( obj => { const { cell, highlightId, prevGlyph, prevGlyphColor, prevGlyphTooltip, navigatorHighlightId } = obj; if ( cell.isElement() ) { this.unhighlight( cell, highlightId ); if ( cell.statusIcon() !== prevGlyph ) { cell.statusIcon( prevGlyph ); } if ( cell.statusIconColor() !== prevGlyphColor ) { cell.statusIconColor( prevGlyphColor ); } if ( cell.statusIconTooltip() !== prevGlyphTooltip ) { cell.statusIconTooltip( prevGlyphTooltip ); } if ( navigatorHighlightId ) { const cellViewInNavigator = navPaper.findViewByModel( cell ); this.unhighlight( cellViewInNavigator, navigatorHighlightId ); } } else { cell.findView( paper ).removeCls( "route-link" ); cell.route( false ); } } ); } this.#routeHighlightsSave = null; this.#highlightedRoute = []; } hasHighlightedRoute() { return !!this.#highlightedRoute.length; } getHighlightedRoute() { return this.#highlightedRoute; } addSubcontainersToContainerElement( containerElement, count, opt = { /* index, arrangeSubcontainers: true, deepArrange: true */ } ) { opt.rtl = this.#config.rtl; return diaActions.addSubcontainersToContainerElement( this.#graph, containerElement, count, opt ); } resizeSubcontainer( subcontainerElement, width, height, opt = { /* moveSiblings: true, deepMove: true */ } ) { subcontainerElement.resize( width, height, opt ); } getSubcontainerAtIndex( containerElement, index ) { return containerElement.getEmbeddedCells().find( sub => sub.positionIndex() === index ); } embed( cell, parentElement, opt = {} ) { cell = this.#getCell( cell ); parentElement = this.#getElement( parentElement ); const { subcontainerBehavior } = this.#config; if ( cell && parentElement ) { const valid = graphUtils.validateEmbed( parentElement, cell, this.#config.filterEmbeds ); if ( valid ) { if ( commonUtils.isPoint( opt.position ) ) { const { x, y } = opt.position; cell.position( x, y ); } diaActions.embedCellInto( cell, parentElement, { clipToParent: subcontainerBehavior === SubcontainerBehavior.CLIP } ); if ( cell.isElement() && subcontainerBehavior === SubcontainerBehavior.RESTRICT_TRANSLATE ) { const { transition = true } = opt; diaActions.restrictToParent( this.#paper.findViewByModel( cell ), { transition } ); } return true; } } return false; } /** * Unembeds a cell (i.e. removes it from its parent). * * @param {(string|Object)} cell - The cell's ID or the cell reference. * @returns {boolean} Whether the cell was found and unembedded. */ unembed( cell, disconnectLinks = true ) { cell = this.#getCell( cell ); if ( cell ) { const cellIsContainer = graphUtils.isContainer( parent ); const cellIsSubcontainer = graphUtils.isSubcontainer( parent ); if ( !cellIsContainer && !cellIsSubcontainer && cell.isEmbedded() ) { // NOTE: disconnectLinks may collide with requireSourceElement & requireTargetElement if ( disconnectLinks ) { if ( cell.isElement() ) { this.#graph.getConnectedLinks( cell, { outbound: true } ).forEach( link => link.source( link.getSourcePoint() ) ); this.#graph.getConnectedLinks( cell, { inbound: true } ).forEach( link => link.target( link.getTargetPoint() ) ); } else { cell.target( cell.getTargetPoint() ); cell.source( cell.getSourcePoint() ); } } if ( cell.get( "clip" ) ) { cell.unset( "clip" ); } cell.getParentCell().unembed( cell ); return true; } } return false; } expandContainer( element ) { element = this.#getElement( element ); if ( element && graphUtils.isContainer( element ) && !element.isExpanded() ) { element.expand(); } } collapseContainer( element ) { element = this.#getElement( element ); if ( element && graphUtils.isContainer( element ) && element.isExpanded() ) { element.collapse(); } } hasExpandedContainer() { return this.#viewController.hasExpandedContainer(); } getExpandedContainer() { return this.#viewController.getExpandedContainer(); } moveSubcontainer( subcontainer, direction, scrollTo = true ) { subcontainer = this.#getCell( subcontainer ); if ( subcontainer && graphUtils.isSubcontainer( subcontainer ) ) { const result = diaActions.moveSubcontainer( subcontainer, direction ); if ( result && scrollTo ) { this.scrollIntoView( subcontainer ); } } } subcontainerHasPrevSibling( subcontainer ) { return graphUtils.subcontainerHasPrevSibling( subcontainer ); } subcontainerHasNextSibling( subcontainer ) { return graphUtils.subcontainerHasNextSibling( subcontainer ); } lockScroll() { this.#paperScroller.lock(); } unlockScroll( force = false ) { this.#paperScroller.unlock( force ); } isScrollLocked() { return this.#paperScroller.isLocked(); } getScrollLockCount() { return this.#paperScroller.getScrollLockCount(); } getLoopedLinksOfElement( el ) { return graphUtils.getLoopedLinksOfElement( el ); } } // Add consts for events, routers, keyboard actions... staticConsts( DiagramBuilder, { Event: DiagramBuilderEvent, Router: DiagramBuilderRouter, KeyboardAction: DiagramBuilderKeyboardAction, SelectionMode: DiagramBuilderSelectionMode, Locale: DiagramBuilderLocale, Util: DiagramBuilderUtil } );