Files
racket-wiki/static/js/cmap/engine/drawing-surface.js
T

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 };