Files
datascape/assets/canvas/interact.js
T
2026-09-22 21:28:28 +02:00

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