/** * Own the diagram surface, component relations and pointer interaction. * * DrawingSurface is internal to DiagramEngine. It coordinates the primitive * renderers, performs hit testing and translates drag gestures to geometry. * Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License. */ import { ComponentList, DisabledConnectorList } from "./drawing-collections.js"; import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js"; import { DrawingTriple as Triple, LinkConnectorRelation } from "./drawing-relations.js"; import { Component, dom, helper } from "./drawing-support.js"; class Cmap extends Component { constructor(rootElement) { super(); this.componentList = this.prop(new ComponentList()); this.disabledConnectorList = this.prop(new DisabledConnectorList()); this.dragDisabledComponentList = this.prop(new ComponentList()); this.element = this.prop(null); this.rootElement = this.prop(rootElement || null); this.retainerElement = this.prop(null); this.dragContext = this.prop({}); this.selectionHandler = null; this.activationHandler = null; this.lastClickComponent = null; this.lastClickTime = 0; this.zoomFactor = 1; this.markDirty(); } add(component) { component.parentElement(this.element()); this.componentList().add(component); this.updateZIndex(); } static anotherConnectionType(type) { if (type === Cmap.CONNECTION_TYPE_SOURCE) return Cmap.CONNECTION_TYPE_TARGET; else if (type === Cmap.CONNECTION_TYPE_TARGET) return Cmap.CONNECTION_TYPE_SOURCE; } remove(component) { component.parentElement(null); if (component instanceof Link) this.hideConnectors(component); this.disconnect(component); this.componentList().remove(component); this.updateZIndex(); } toFront(component) { this.componentList().toFront(component); this.updateZIndex(); } updateZIndex() { var linkIndex = 0; var nodeIndex = 0; this.componentList().toArray().forEach(function(component) { if (component instanceof Connector) return; // Relations always occupy a lower band than concept nodes. Reordering a // selected component therefore only changes its order inside that band. var zIndex = component instanceof Link ? Cmap.LINK_Z_INDEX_BASE + linkIndex++ : Cmap.NODE_Z_INDEX_BASE + nodeIndex++; component.zIndex(zIndex); if (!(component instanceof Link)) return; // update connector z-index of link helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) { relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index); }); }); } connect(type, node, link) { var linkRelations = link.relations(); var triple = helper.firstInstance(linkRelations, Triple); var nodeKey = type + 'Node'; if (triple && triple[nodeKey]()) throw new Error('Already connected'); var anotherType = Cmap.anotherConnectionType(type); var anotherSideNode = triple ? triple[anotherType + 'Node']() : null; if (anotherSideNode === node) throw new Error('Already connected to the ' + anotherType + ' of the link'); if (triple) { triple[nodeKey](node); } else { var tripleProps = {}; tripleProps.link = link; tripleProps[nodeKey] = node; triple = new Triple(tripleProps); // add triple to the beginning of link relations to be ahead of link-connector relation // connector position won't be updated before triple update linkRelations.unshift(triple); } // add triple to node node.relations().push(triple); triple.updateNodePositionsCache(); // update connectors of link helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) { if (relation.type() === type) relation.isConnected(true); }); // link content moves to midpoint of connected nodes if (anotherSideNode) { link.cx((node.cx() + anotherSideNode.cx()) / 2); link.cy((node.cy() + anotherSideNode.cy()) / 2); } // do not need to mark node dirty (stay unchanged) link.markDirty(); } disconnect(type, node, link) { if (type instanceof Component) { var component = type; var relations = component.relations().slice(); // disconnect all connections of component helper.eachInstance(relations, Triple, function(triple) { var link = triple.link(); var sourceNode = triple.sourceNode(); var targetNode = triple.targetNode(); if (sourceNode && (component === link || component === sourceNode)) this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link); if (targetNode && (component === link || component === targetNode)) this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link); }.bind(this)); return; } var linkRelations = link.relations(); var triple = helper.firstInstance(linkRelations, Triple); var nodeKey = type + 'Node'; if (!triple || triple[nodeKey]() !== node) throw new Error('Not connected'); triple[nodeKey](null); // remove triple from node var nodeRelations = node.relations(); nodeRelations.splice(nodeRelations.indexOf(triple), 1); // remove triple from link if (!triple.sourceNode() && !triple.targetNode()) linkRelations.splice(linkRelations.indexOf(triple), 1); // update connectors of link helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) { if (relation.type() === type) relation.isConnected(false); }); // do not need to mark node dirty (stay unchanged) link.markDirty(); } connectedNode(type, link) { var triple = helper.firstInstance(link.relations(), Triple); if (!triple) return null; return triple[type + 'Node'](); } showConnector(type, link) { if (this.connectorVisible(type, link)) return; var disabledConnectorList = this.disabledConnectorList(); var connectorDisabled = disabledConnectorList.contains(type, link); if (!connectorDisabled) this.addConnector(type, link); } connectorVisible(type, link) { return link.relations().some(function(relation) { return relation instanceof LinkConnectorRelation && relation.type() === type; }); } addConnector(type, link) { var connector = new Connector({ x: link[type + 'X'](), y: link[type + 'Y']() }); var linkConnectorRelation = new LinkConnectorRelation({ type: type, link: link, connector: connector }); var linkRelations = link.relations(); var triple = helper.firstInstance(linkRelations, Triple); var isConnected = (triple && !!triple[type + 'Node']()); linkConnectorRelation.isConnected(isConnected); linkRelations.push(linkConnectorRelation); connector.relations().push(linkConnectorRelation); this.add(connector); } hideConnector(type, link) { var linkRelations = link.relations(); for (var i = linkRelations.length - 1; i >= 0; i--) { var relation = linkRelations[i]; if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type) continue; // remove connector component this.remove(relation.connector()); // remove link-connector relation from link linkRelations.splice(i, 1); break; } } showConnectors(link) { this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link); this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link); } hideConnectors(link) { this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link); this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link); } hideAllConnectors() { this.componentList().toArray().forEach(function(component) { if (component instanceof Link) this.hideConnectors(component); }.bind(this)); } enableConnector(type, link) { this.disabledConnectorList().remove(type, link); } disableConnector(type, link) { // remove showing connector this.hideConnector(type, link); this.disabledConnectorList().add(type, link); } connectorEnabled(type, link) { return !this.disabledConnectorList().contains(type, link); } enableDrag(component) { this.dragDisabledComponentList().remove(component); } disableDrag(component) { this.dragDisabledComponentList().add(component); } dragEnabled(component) { return !this.dragDisabledComponentList().contains(component); } onstart(x, y, event) { var context = this.dragContext(); var component = this.componentList().fromPoint(Component, x, y); context.component = component; if (typeof this.selectionHandler === 'function') this.selectionHandler(component, event); if (!(component instanceof Connector)) this.hideAllConnectors(); if (!component) return; var draggable = !this.dragDisabledComponentList().contains(component); context.draggable = draggable; if (!draggable) return; dom.cancel(event); this.toFront(component); if (component instanceof Node) { context.x = component.x(); context.y = component.y(); } else if (component instanceof Link) { context.cx = component.cx(); context.cy = component.cy(); context.sourceX = component.sourceX(); context.sourceY = component.sourceY(); context.targetX = component.targetX(); context.targetY = component.targetY(); context.triple = helper.firstInstance(component.relations(), Triple); this.showConnectors(component); } else if (component instanceof Connector) { var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation); context.x = x; context.y = y; context.type = linkConnectorRelation.type(); context.link = linkConnectorRelation.link(); } this.fixScrollSize(); } onmove(dx, dy, event) { var context = this.dragContext(); var component = context.component; if (!component) return; if (!context.draggable) return; if (component instanceof Node) { var nodeX = context.x + dx; var nodeY = context.y + dy; if (typeof component.moveHandler === 'function') { var constrainedPosition = component.moveHandler(nodeX, nodeY); if (constrainedPosition && isFinite(constrainedPosition.x) && isFinite(constrainedPosition.y)) { nodeX = constrainedPosition.x; nodeY = constrainedPosition.y; } } component.x(nodeX); component.y(nodeY); } else if (component instanceof Link) { var cx = context.cx + dx; var cy = context.cy + dy; var triple = context.triple; var connectedNode = null; if (triple) { var sourceNode = triple.sourceNode(); var targetNode = triple.targetNode(); if (sourceNode && !targetNode) connectedNode = sourceNode; else if (!sourceNode && targetNode) connectedNode = targetNode; } if (connectedNode) { // only one node connected var x = cx - connectedNode.cx(); var y = cy - connectedNode.cy(); triple.updateLinkAngle(Math.atan2(y, x)); triple.skipNextUpdate(true); } else if (!triple || component.content()) { // not connected or link has content // (except two nodes connected but link has no content) component.cx(cx); component.cy(cy); component.sourceX(context.sourceX + dx); component.sourceY(context.sourceY + dy); component.targetX(context.targetX + dx); component.targetY(context.targetY + dy); } } else if (component instanceof Connector) { var x = context.x + dx; var y = context.y + dy; var type = context.type; var link = context.link; var triple = helper.firstInstance(link.relations(), Triple); var connectedNode = triple ? triple[type + 'Node']() : null; var node = this.componentList().fromPoint(Node, x, y); if (connectedNode && connectedNode === node) { // already connected (do nothing) return; } var anotherType = Cmap.anotherConnectionType(type); var anotherSideNode = triple ? triple[anotherType + 'Node']() : null; if (connectedNode && connectedNode !== node) { this.disconnect(type, connectedNode, link); connectedNode = null; } var needsConnect = !connectedNode && node && anotherSideNode !== node; if (needsConnect) { if (anotherSideNode) { var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy()); link[type + 'X'](p.x); link[type + 'Y'](p.y); triple.update(link); triple.skipNextUpdate(true); } this.connect(type, node, link); } else { link[type + 'X'](x); link[type + 'Y'](y); if (!anotherSideNode) link.straighten(); } } } onend(dx, dy, event) { var context = this.dragContext(); var component = context.component; if (!component) { this.lastClickComponent = null; this.lastClickTime = 0; return; } if (!context.draggable) { this.lastClickComponent = null; this.lastClickTime = 0; return; } // dx/dy are logical map coordinates; keep the click tolerance at four // physical screen pixels at every zoom level. var clickTolerance = 4 / this.zoomFactor; var isClick = Math.abs(dx) <= clickTolerance && Math.abs(dy) <= clickTolerance; if (isClick) { var now = Date.now(); var isDoubleClick = (component === this.lastClickComponent && now - this.lastClickTime <= 500); if (isDoubleClick) { this.lastClickComponent = null; this.lastClickTime = 0; if (typeof this.activationHandler === 'function') this.activationHandler(component, event); } else { this.lastClickComponent = component; this.lastClickTime = now; } } else { this.lastClickComponent = null; this.lastClickTime = 0; } if (component instanceof Node && typeof component.moveEndHandler === 'function') component.moveEndHandler(component.x(), component.y(), event); if (component instanceof Connector) { var link = context.link; var triple = helper.firstInstance(link.relations(), Triple); var connectedNode = triple ? triple[context.type + 'Node']() : null; if (typeof link.connectionChangeHandler === 'function') link.connectionChangeHandler(context.type, connectedNode, event); } this.unfixScrollSize(); } fixScrollSize() { var element = this.element(); var clientWidth = dom.clientWidth(element); var clientHeight = dom.clientHeight(element); var scrollWidth = dom.scrollWidth(element); var scrollHeight = dom.scrollHeight(element); // check if scrolled if (clientWidth === scrollWidth && clientHeight === scrollHeight) return; var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)'; dom.css(this.retainerElement(), { msTransform: translate, transform: translate, webkitTransform: translate }); } unfixScrollSize() { var translate = 'translate(-1px, -1px)'; dom.css(this.retainerElement(), { msTransform: translate, transform: translate, webkitTransform: translate }); } style() { return { color: '#333', cursor: 'default', fontFamily: 'sans-serif', fontSize: '14px', height: '100%', MozUserSelect: 'none', msUserSelect: 'none', overflow: 'visible', position: 'relative', userSelect: 'none', webkitUserSelect: 'none', width: '100%', zoom: this.zoomFactor }; } retainerStyle() { return { height: '1px', pointerEvents: 'none', position: 'absolute', width: '1px' }; } redraw() { if (this.disposed) return; var rootElement = this.rootElement(); if (!rootElement) { rootElement = dom.body(); dom.css(rootElement, { height: '100vh', margin: '0', width: '100vw' }); this.rootElement(rootElement); } var previousElement = this.element(); var element = dom.el('