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