616 lines
18 KiB
JavaScript
616 lines
18 KiB
JavaScript
/**
|
|
* 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('<div>');
|
|
element.className = 'rw-cmap-surface';
|
|
dom.draggable(element, function(x, y, event) {
|
|
this.onstart(x / this.zoomFactor, y / this.zoomFactor, event);
|
|
}.bind(this), function(dx, dy, event) {
|
|
this.onmove(dx / this.zoomFactor, dy / this.zoomFactor, event);
|
|
}.bind(this), function(dx, dy, event) {
|
|
this.onend(dx / this.zoomFactor, dy / this.zoomFactor, event);
|
|
}.bind(this));
|
|
this.element(element);
|
|
|
|
this.componentList().toArray().forEach(function(component) {
|
|
component.parentElement(element);
|
|
});
|
|
|
|
var retainerElement = dom.el('<div>');
|
|
dom.css(retainerElement, this.retainerStyle());
|
|
dom.append(element, retainerElement);
|
|
this.retainerElement(retainerElement);
|
|
|
|
// set initial position of retainer
|
|
this.unfixScrollSize();
|
|
|
|
dom.css(element, this.style());
|
|
if (previousElement && previousElement.parentNode)
|
|
previousElement.parentNode.removeChild(previousElement);
|
|
dom.append(rootElement, element);
|
|
}
|
|
}
|
|
|
|
Cmap.LINK_Z_INDEX_BASE = 100;
|
|
Cmap.NODE_Z_INDEX_BASE = 100000;
|
|
Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
|
|
Cmap.CONNECTION_TYPE_SOURCE = 'source';
|
|
Cmap.CONNECTION_TYPE_TARGET = 'target';
|
|
|
|
export { Cmap as DrawingSurface };
|