462 lines
16 KiB
JavaScript
462 lines
16 KiB
JavaScript
// interact.js — pointer and keyboard gestures.
|
|
//
|
|
// Every gesture is bracketed by Doc.begin()/Doc.commit(), so a drag that spans
|
|
// hundreds of pointermove events collapses into one undo entry. Within a
|
|
// gesture the document is mutated directly and only the affected elements are
|
|
// re-synced; the full reconcile happens once, on commit.
|
|
//
|
|
// Gesture routing on pointerdown, in priority order:
|
|
// resize handle -> resize port -> draw an edge
|
|
// node -> select + drag edge -> select
|
|
// background -> pan (shift: marquee select; two fingers: pinch zoom)
|
|
window.Canvas = window.Canvas || {};
|
|
|
|
Canvas.Interact = (function () {
|
|
var Doc = Canvas.Doc;
|
|
var View = Canvas.View;
|
|
var Select = Canvas.Select;
|
|
var Nodes = Canvas.Nodes;
|
|
var Edges = Canvas.Edges;
|
|
|
|
var MIN_SIZE = 40;
|
|
var DRAG_SLOP = 3;
|
|
|
|
var surface = null;
|
|
var marquee = null;
|
|
var gesture = null;
|
|
var pointers = {};
|
|
|
|
function snap(v) {
|
|
return Math.round(v / View.GRID) * View.GRID;
|
|
}
|
|
|
|
function pointerCount() {
|
|
return Object.keys(pointers).length;
|
|
}
|
|
|
|
// interactive reports whether the pointer landed on something the browser
|
|
// should handle itself (a link, a video control), which must not become the
|
|
// start of a node drag.
|
|
function interactive(target) {
|
|
return !!target.closest('a, button, input, select, textarea, video, embed, iframe');
|
|
}
|
|
|
|
// groupMembers returns the nodes whose centre sits inside a group, which is
|
|
// what "inside" means for dragging: a node straddling the border belongs to
|
|
// whichever side holds most of it.
|
|
function groupMembers(g) {
|
|
return Doc.nodes().filter(function (n) {
|
|
if (n.id === g.id) return false;
|
|
var cx = n.x + n.width / 2;
|
|
var cy = n.y + n.height / 2;
|
|
return cx >= g.x && cx <= g.x + g.width && cy >= g.y && cy <= g.y + g.height;
|
|
}).map(function (n) { return n.id; });
|
|
}
|
|
|
|
// withGroupMembers expands a selection so dragging a group carries whatever
|
|
// it contains.
|
|
function withGroupMembers(ids) {
|
|
var out = {};
|
|
ids.forEach(function (id) {
|
|
out[id] = true;
|
|
var n = Doc.node(id);
|
|
if (n && n.type === 'group') {
|
|
groupMembers(n).forEach(function (m) { out[m] = true; });
|
|
}
|
|
});
|
|
return Object.keys(out);
|
|
}
|
|
|
|
function selectOnPress(id, additive) {
|
|
if (additive) {
|
|
Select.toggleNode(id);
|
|
return;
|
|
}
|
|
if (!Select.hasNode(id)) Select.set([id], []);
|
|
}
|
|
|
|
// --- gesture starts ---
|
|
|
|
function startDrag(id, e) {
|
|
selectOnPress(id, e.shiftKey);
|
|
if (!Select.hasNode(id)) return; // shift-click deselected it
|
|
var ids = withGroupMembers(Select.nodes());
|
|
Doc.begin('move');
|
|
gesture = {
|
|
kind: 'drag',
|
|
ids: ids,
|
|
start: View.toWorld(e.clientX, e.clientY),
|
|
origin: ids.map(function (nid) {
|
|
var n = Doc.node(nid);
|
|
return { id: nid, x: n.x, y: n.y };
|
|
}),
|
|
moved: false
|
|
};
|
|
}
|
|
|
|
function startResize(id, corner, e) {
|
|
Select.set([id], []);
|
|
var n = Doc.node(id);
|
|
Doc.begin('resize');
|
|
gesture = {
|
|
kind: 'resize',
|
|
id: id,
|
|
corner: corner,
|
|
start: View.toWorld(e.clientX, e.clientY),
|
|
origin: { x: n.x, y: n.y, width: n.width, height: n.height },
|
|
moved: false
|
|
};
|
|
}
|
|
|
|
function startEdge(id, side) {
|
|
Edges.beginTemp();
|
|
gesture = { kind: 'edge', fromId: id, fromSide: side, target: null, moved: false };
|
|
}
|
|
|
|
function startPan(e) {
|
|
gesture = { kind: 'pan', last: { x: e.clientX, y: e.clientY }, moved: false };
|
|
}
|
|
|
|
function startMarquee(e) {
|
|
var r = surface.getBoundingClientRect();
|
|
gesture = {
|
|
kind: 'marquee',
|
|
origin: { x: e.clientX - r.left, y: e.clientY - r.top },
|
|
additive: e.ctrlKey || e.metaKey,
|
|
moved: false
|
|
};
|
|
marquee.hidden = false;
|
|
updateMarquee(e);
|
|
}
|
|
|
|
function startPinch() {
|
|
var ids = Object.keys(pointers);
|
|
var a = pointers[ids[0]];
|
|
var b = pointers[ids[1]];
|
|
gesture = {
|
|
kind: 'pinch',
|
|
dist: Math.hypot(b.x - a.x, b.y - a.y),
|
|
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
moved: true
|
|
};
|
|
}
|
|
|
|
// --- gesture updates ---
|
|
|
|
function moveDrag(e) {
|
|
var p = View.toWorld(e.clientX, e.clientY);
|
|
var lead = gesture.origin[0];
|
|
if (!lead) return;
|
|
var dx = p.x - gesture.start.x;
|
|
var dy = p.y - gesture.start.y;
|
|
// Snap the lead node to the grid and shift the rest by the same amount,
|
|
// so a multi-node drag keeps its relative layout. Alt opts out.
|
|
if (!e.altKey) {
|
|
dx = snap(lead.x + dx) - lead.x;
|
|
dy = snap(lead.y + dy) - lead.y;
|
|
}
|
|
gesture.origin.forEach(function (o) {
|
|
var n = Doc.node(o.id);
|
|
if (!n) return;
|
|
n.x = Math.round(o.x + dx);
|
|
n.y = Math.round(o.y + dy);
|
|
});
|
|
gesture.ids.forEach(Nodes.syncGeom);
|
|
Edges.syncFor(gesture.ids);
|
|
}
|
|
|
|
function moveResize(e) {
|
|
var n = Doc.node(gesture.id);
|
|
if (!n) return;
|
|
var p = View.toWorld(e.clientX, e.clientY);
|
|
var o = gesture.origin;
|
|
var dx = p.x - gesture.start.x;
|
|
var dy = p.y - gesture.start.y;
|
|
var right = o.x + o.width;
|
|
var bottom = o.y + o.height;
|
|
var x = o.x, y = o.y, w = o.width, h = o.height;
|
|
|
|
if (gesture.corner.indexOf('w') !== -1) {
|
|
x = e.altKey ? o.x + dx : snap(o.x + dx);
|
|
w = right - x;
|
|
} else {
|
|
w = e.altKey ? o.width + dx : snap(right + dx) - o.x;
|
|
}
|
|
if (gesture.corner.indexOf('n') !== -1) {
|
|
y = e.altKey ? o.y + dy : snap(o.y + dy);
|
|
h = bottom - y;
|
|
} else {
|
|
h = e.altKey ? o.height + dy : snap(bottom + dy) - o.y;
|
|
}
|
|
|
|
// Clamp against the fixed edge so shrinking past the minimum pins the
|
|
// node instead of flipping it inside out.
|
|
if (w < MIN_SIZE) {
|
|
if (gesture.corner.indexOf('w') !== -1) x = right - MIN_SIZE;
|
|
w = MIN_SIZE;
|
|
}
|
|
if (h < MIN_SIZE) {
|
|
if (gesture.corner.indexOf('n') !== -1) y = bottom - MIN_SIZE;
|
|
h = MIN_SIZE;
|
|
}
|
|
|
|
n.x = Math.round(x);
|
|
n.y = Math.round(y);
|
|
n.width = Math.round(w);
|
|
n.height = Math.round(h);
|
|
Nodes.syncGeom(n.id);
|
|
Edges.syncFor([n.id]);
|
|
}
|
|
|
|
function moveEdge(e) {
|
|
var from = Doc.node(gesture.fromId);
|
|
if (!from) return;
|
|
Edges.updateTemp(from, gesture.fromSide, View.toWorld(e.clientX, e.clientY));
|
|
|
|
var under = document.elementFromPoint(e.clientX, e.clientY);
|
|
var nodeEl = under && under.closest ? under.closest('.canvas-node') : null;
|
|
var id = nodeEl ? nodeEl.dataset.id : null;
|
|
if (id === gesture.target) return;
|
|
if (gesture.target) {
|
|
var prev = Nodes.el(gesture.target);
|
|
if (prev) prev.classList.remove('is-edge-target');
|
|
}
|
|
gesture.target = id;
|
|
if (id && nodeEl) nodeEl.classList.add('is-edge-target');
|
|
}
|
|
|
|
function updateMarquee(e) {
|
|
var r = surface.getBoundingClientRect();
|
|
var x = e.clientX - r.left;
|
|
var y = e.clientY - r.top;
|
|
marquee.style.left = Math.min(x, gesture.origin.x) + 'px';
|
|
marquee.style.top = Math.min(y, gesture.origin.y) + 'px';
|
|
marquee.style.width = Math.abs(x - gesture.origin.x) + 'px';
|
|
marquee.style.height = Math.abs(y - gesture.origin.y) + 'px';
|
|
}
|
|
|
|
function movePinch() {
|
|
var ids = Object.keys(pointers);
|
|
if (ids.length < 2) return;
|
|
var a = pointers[ids[0]];
|
|
var b = pointers[ids[1]];
|
|
var dist = Math.hypot(b.x - a.x, b.y - a.y);
|
|
var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
if (gesture.dist > 0) View.zoomAt(mid.x, mid.y, dist / gesture.dist);
|
|
View.pan(mid.x - gesture.mid.x, mid.y - gesture.mid.y);
|
|
gesture.dist = dist;
|
|
gesture.mid = mid;
|
|
}
|
|
|
|
// --- gesture ends ---
|
|
|
|
// Both finishers take the gesture as an argument rather than reading the
|
|
// module-level one: endGesture clears it before dispatching so a finisher
|
|
// cannot accidentally leave a half-torn-down gesture in place.
|
|
function finishMarquee(g) {
|
|
marquee.hidden = true;
|
|
var r = surface.getBoundingClientRect();
|
|
var a = View.toWorld(r.left + parseFloat(marquee.style.left),
|
|
r.top + parseFloat(marquee.style.top));
|
|
var b = View.toWorld(r.left + parseFloat(marquee.style.left) + parseFloat(marquee.style.width),
|
|
r.top + parseFloat(marquee.style.top) + parseFloat(marquee.style.height));
|
|
var hit = Doc.nodes().filter(function (n) {
|
|
return n.x < b.x && n.x + n.width > a.x && n.y < b.y && n.y + n.height > a.y;
|
|
}).map(function (n) { return n.id; });
|
|
if (g.additive) {
|
|
hit.forEach(Select.addNode);
|
|
} else {
|
|
Select.set(hit, []);
|
|
}
|
|
}
|
|
|
|
function finishEdge(g) {
|
|
if (g.target) {
|
|
var el = Nodes.el(g.target);
|
|
if (el) el.classList.remove('is-edge-target');
|
|
}
|
|
Edges.endTemp();
|
|
if (!g.target) return;
|
|
Doc.begin('connect');
|
|
Doc.addEdge({
|
|
id: Doc.newId(),
|
|
fromNode: g.fromId,
|
|
fromSide: g.fromSide,
|
|
toNode: g.target
|
|
});
|
|
Doc.commit();
|
|
}
|
|
|
|
// --- event wiring ---
|
|
|
|
function onDown(e) {
|
|
if (Canvas.Edit) Canvas.Edit.close();
|
|
pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
|
|
|
|
if (pointerCount() === 2 && (!gesture || gesture.kind === 'pan')) {
|
|
if (gesture) endGesture(true);
|
|
startPinch();
|
|
return;
|
|
}
|
|
if (gesture) return; // one gesture at a time
|
|
|
|
var t = e.target;
|
|
var handle = t.closest('.canvas-handle');
|
|
var port = t.closest('.canvas-port');
|
|
var nodeEl = t.closest('.canvas-node');
|
|
var edgeEl = t.closest('.canvas-edge');
|
|
|
|
if (handle && nodeEl) {
|
|
startResize(nodeEl.dataset.id, handle.dataset.corner, e);
|
|
} else if (port && nodeEl) {
|
|
startEdge(nodeEl.dataset.id, port.dataset.side);
|
|
} else if (nodeEl) {
|
|
if (interactive(t)) return;
|
|
startDrag(nodeEl.dataset.id, e);
|
|
} else if (edgeEl) {
|
|
if (e.shiftKey) Select.toggleEdge(edgeEl.dataset.id);
|
|
else Select.set([], [edgeEl.dataset.id]);
|
|
return;
|
|
} else if (e.shiftKey) {
|
|
startMarquee(e);
|
|
} else {
|
|
startPan(e);
|
|
}
|
|
|
|
if (gesture) {
|
|
// Capture keeps the gesture alive when the pointer leaves the
|
|
// surface mid-drag. It throws when the pointer is already gone (a
|
|
// release that raced the handler), which must not abort the
|
|
// gesture — that would leave it stuck with no pointerup to end it.
|
|
try { surface.setPointerCapture(e.pointerId); } catch (err) { /* pointer already released */ }
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
function onMove(e) {
|
|
if (pointers[e.pointerId]) {
|
|
pointers[e.pointerId].x = e.clientX;
|
|
pointers[e.pointerId].y = e.clientY;
|
|
}
|
|
if (!gesture) return;
|
|
|
|
if (gesture.kind === 'pinch') { movePinch(); return; }
|
|
|
|
if (!gesture.moved) {
|
|
// Ignore sub-pixel jitter so a plain click does not register as a
|
|
// drag (and so a click on the background still clears the selection).
|
|
var dx = Math.abs(e.movementX || 0);
|
|
var dy = Math.abs(e.movementY || 0);
|
|
if (dx + dy < DRAG_SLOP && gesture.kind !== 'edge') return;
|
|
gesture.moved = true;
|
|
}
|
|
|
|
switch (gesture.kind) {
|
|
case 'drag': moveDrag(e); break;
|
|
case 'resize': moveResize(e); break;
|
|
case 'edge': moveEdge(e); break;
|
|
case 'marquee': updateMarquee(e); break;
|
|
case 'pan':
|
|
View.pan(e.clientX - gesture.last.x, e.clientY - gesture.last.y);
|
|
gesture.last = { x: e.clientX, y: e.clientY };
|
|
break;
|
|
}
|
|
}
|
|
|
|
function endGesture(silent) {
|
|
if (!gesture) return;
|
|
var g = gesture;
|
|
gesture = null;
|
|
switch (g.kind) {
|
|
case 'drag':
|
|
case 'resize':
|
|
Doc.commit();
|
|
break;
|
|
case 'edge':
|
|
if (!silent) finishEdge(g); else Edges.endTemp();
|
|
break;
|
|
case 'marquee':
|
|
if (!silent) finishMarquee(g); else marquee.hidden = true;
|
|
break;
|
|
case 'pan':
|
|
// A press on the background that never moved is a click, and a
|
|
// click on empty space clears the selection.
|
|
if (!silent && !g.moved) Select.clear();
|
|
break;
|
|
}
|
|
}
|
|
|
|
function onUp(e) {
|
|
delete pointers[e.pointerId];
|
|
if (gesture && gesture.kind === 'pinch' && pointerCount() < 2) {
|
|
gesture = null;
|
|
return;
|
|
}
|
|
endGesture(false);
|
|
}
|
|
|
|
function onWheel(e) {
|
|
// Ctrl/meta+wheel is the zoom gesture every drawing tool uses, and is
|
|
// also what a trackpad pinch reports. A plain wheel scrolls the canvas.
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
View.zoomAt(e.clientX, e.clientY, Math.pow(0.999, e.deltaY));
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
if (e.shiftKey) View.pan(-e.deltaY, 0);
|
|
else View.pan(-e.deltaX, -e.deltaY);
|
|
}
|
|
|
|
function onDblClick(e) {
|
|
var nodeEl = e.target.closest('.canvas-node');
|
|
if (nodeEl) {
|
|
if (interactive(e.target)) return;
|
|
e.preventDefault();
|
|
Canvas.Edit.open(nodeEl.dataset.id);
|
|
return;
|
|
}
|
|
if (e.target.closest('.canvas-edge')) return;
|
|
// Double-clicking empty space drops a new text node there and opens it,
|
|
// which is how a canvas normally gets its first node.
|
|
var p = View.toWorld(e.clientX, e.clientY);
|
|
var node = Canvas.Actions.addText(p);
|
|
if (node) Canvas.Edit.open(node.id);
|
|
}
|
|
|
|
return {
|
|
init: function (surfaceEl, marqueeEl) {
|
|
surface = surfaceEl;
|
|
marquee = marqueeEl;
|
|
|
|
surface.addEventListener('pointerdown', onDown);
|
|
surface.addEventListener('pointermove', onMove);
|
|
surface.addEventListener('pointerup', onUp);
|
|
surface.addEventListener('pointercancel', onUp);
|
|
surface.addEventListener('wheel', onWheel, { passive: false });
|
|
surface.addEventListener('dblclick', onDblClick);
|
|
// Native drag would fight the pointer gestures on images and links.
|
|
surface.addEventListener('dragstart', function (e) { e.preventDefault(); });
|
|
surface.addEventListener('contextmenu', function (e) {
|
|
if (e.target.closest('.canvas-node, .canvas-edge')) e.preventDefault();
|
|
});
|
|
},
|
|
|
|
// cancel aborts an in-flight gesture and rolls the document back, for
|
|
// the Escape key.
|
|
cancel: function () {
|
|
if (!gesture) return false;
|
|
var kind = gesture.kind;
|
|
if (kind === 'drag' || kind === 'resize') {
|
|
gesture = null;
|
|
Doc.abort();
|
|
return true;
|
|
}
|
|
endGesture(true);
|
|
return true;
|
|
},
|
|
|
|
active: function () { return gesture !== null; }
|
|
};
|
|
})();
|