/*!
* Copyright (c) 2023, 2024, 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 * 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 CellSelection 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';
const { dia, ui, shapes, connectionStrategies, g, util, mvc } = joint;
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,
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,
// 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) {
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,
style: cfg.style,
locale: cfg.locale,
elementProps: {
onRemoveKeepOutboundLinks: !!cfg.onElementRemoveKeepOutboundLinks,
onRemoveKeepInboundLinks: !!cfg.onElementRemoveKeepInboundLinks
}
// 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
});
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
});
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) => {
this.#dispatch(cell.isElement() ? E.ELEMENT_ADD : E.LINK_ADD, cell);
},
'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, source) => {
this.#dispatch(E.LINK_TARGET_CHANGE, link, source);
},
'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) => {
this.#dispatch(E.CELL_Z_INDEX_CHANGE, cell, z);
}
});
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);
}
});
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 { 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(' ')));
}
// 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();
}
#initGraph() {
this.#graph = new Graph({}, { cellNamespace: shapes });
}
#initPaper() {
const {
drawGrid,
initialZoom,
paperWidth,
paperHeight,
requireLinkSourceElement,
requireLinkTargetElement,
style,
defaultRouter
} = this.#config;
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: this.#graph,
cellViewNamespace: shapes,
connectionStrategy: function(end, view, magnet, coords, linkModel, endString, paper, side) {
if (side) {
const distanceFromPoint = 10;
let dx = 0, dy = 0;
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(),
defaultLinkAnchor: {
name: 'connectionClosest'
},
snapLabels: true,
interactive: (view) => {
const { model } = view;
const cellReadOnly = model.isReadOnly();
const ro = this.isReadOnly() || cellReadOnly;
const options = {
elementMove: !ro,
linkMove: false,
labelMove: !ro,
};
return options;
},
// Fires after arrowhead movements
allowLink: (linkView, paper, dragData) => {
const { model } = 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 = model[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);
// 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)
if (viewUnderPointer && viewUnderPointer instanceof dia.ElementView) {
return false;
}
}
return this.#canLink(model.getSourceCell(), model.getTargetCell(), linkView);
},
validateConnection: (cellViewS, magnetS, cellViewT, magnetT, end, linkView) => {
return this.#canLink(cellViewS?.model, cellViewT?.model, linkView);
}
});
this.#paper = paper;
const paperScroller = new PaperScroller({
paper,
scrollWhileDragging: true,
cursor: 'grab',
padding: 30,
autoResizePaper: true,
allowNewOrigin: 'any',
baseWidth: 500,
baseHeight: 500,
contentOptions: () => {
const graphBBox = this.#graph.getBBox(); // 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() {
if (this.#graph.getCells().length) {
this.#paperScroller.centerContent({ useModelGeometry: true });
}
else {
this.#paperScroller.center();
}
}
#canLink(sourceModel, targetModel, linkView) {
const linkModel = linkView.model;
const requireSource = linkModel.get('requireSourceElement');
const requireTarget = linkModel.get('requireTargetElement');
// 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 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;
}
// config validation
if (typeof allowConnection == 'function') {
return allowConnection(sourceModel, targetModel, linkView) !== false;
}
return true;
}
#initRouters() {
defineCustomRouters();
}
#initSelection() {
this.#clipboard = new ui.Clipboard();
this.#selection = new CellSelection({
useModelGeometry: true,
translateConnectedLinks: ui.Selection.ConnectedLinksTranslation.SUBGRAPH,
boxContent: false,
paper: this.#paper,
allowCellInteraction: true,
filter: cell => {
return !cell.isSelectable();
}
});
}
#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: 15,
animation: {
delay: '1s'
}
});
}
#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,
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' }
}
});
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 ui.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
});
}
#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()
}
});
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 = {};
// TODO: add for the stencil shapes as well - not urgent, we are not using 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);
// TODO: add for the stencil shapes as well - not urgent, we are not using 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,
...rest
} = opt;
const graph = this.#graph;
const el = this.#createElement(typeId, { x, y, ...rest });
const { x: centerX, y: centerY } = this.getVisibleAreaCenter();
if (!el) {
return;
}
const { width, height } = el.size();
if (addToLink) {
graph.addCell(el, { async });
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) {
({ 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 });
}
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',
...Object.keys(this.#ELEMENT_TYPES_OPTIONAL_ATTRIBUTES)
];
const extraProps = {};
Object.entries(model.attributes).forEach(([key, value]) => {
if (!filterOut.includes(key)) {
extraProps[key] = value;
}
});
// 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);
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,
...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);
const targetEl = !targetIsPoint && this.#getElement(target);
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)
) {
return;
}
link.source(typeof source === 'string' ? sourceEl : source);
link.target(typeof target === 'string' ? targetEl : target);
this.#graph.addCell(link, { async });
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)) {
// we need to pass the param when not a string as it may contain the anchor
link.target(typeof target === 'string' ? 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 = {}) {
this.deselect();
this.unhighlightRoute();
const { cells: dataCells = [] } = data;
const cells = [];
dataCells.forEach(cell => {
const { typeId } = cell;
const cfg = {};
// If it has typeId, it is an element
if (typeId) {
const def = this.#elementMap.find(def => def.typeId === typeId);
const {
text = '',
statusIcon,
...rest
} = cell;
if (def) {
const mapEl = def.element;
const pattern = mapEl.prop('decorationPattern');
let patternId;
if (pattern) {
patternId = this.#definePattern(pattern);
}
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);
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,
...rest
} = cell;
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;
// 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);
}
});
if (cells.length) {
this.#graph.fromJSON({ cells });
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();
}
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,
});
if (scrollTo) {
this.scrollIntoView(cells[0]);
}
}
}
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.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) {
cells = Array.isArray(cells) ? cells : [cells];
cells = cells.map(cell => this.#getCell(cell)).filter(cell => cell);
if (cells.length) {
this.#freeze();
diaActions.toFront(cells);
this.#unfreeze();
}
}
toBack(cells) {
cells = Array.isArray(cells) ? cells : [cells];
cells = cells.map(cell => this.#getCell(cell)).filter(cell => cell);
if (cells.length) {
this.#freeze();
diaActions.toBack(cells);
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() {
this.#el.focus();
}
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,
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 { glyph: newGlyph = '', glyphColor: newGlyphColor = '#000000' } = stepObj;
if (newGlyph !== cell.statusIcon()) {
toSave.prevGlyph = prevGlyph;
toSave.newGlyph = newGlyph;
}
if (newGlyphColor !== cell.statusIconColor()) {
toSave.prevGlyphColor = prevGlyphColor;
toSave.newGlyphColor = newGlyphColor;
}
}
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',
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;
}
}
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()));
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, navigatorHighlightId } = obj;
if (cell.isElement()) {
this.unhighlight(cell, highlightId);
if (cell.statusIcon() !== prevGlyph) {
cell.statusIcon(prevGlyph);
}
if (cell.statusIconColor() !== prevGlyphColor) {
cell.statusIconColor(prevGlyphColor);
}
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;
}
}
// Add consts for events, routers, keyboard actions...
staticConsts(DiagramBuilder, {
Event: DiagramBuilderEvent,
Router: DiagramBuilderRouter,
KeyboardAction: DiagramBuilderKeyboardAction,
SelectionMode: DiagramBuilderSelectionMode,
Locale: DiagramBuilderLocale,
Util: DiagramBuilderUtil
});