JSON canvas initial

This commit is contained in:
2026-09-17 17:07:04 +02:00
parent b14941b3e0
commit 7797d4a456
22 changed files with 3277 additions and 19 deletions
+182
View File
@@ -0,0 +1,182 @@
// doc.js — the canvas document plus its undo stack.
//
// Every mutation passes through begin()/commit(). A gesture (a drag, a resize,
// a nudge-repeat) spans hundreds of pointer events but has to collapse into a
// single undo entry, so the snapshot is taken once at begin() and pushed once
// at commit() — and only when the document actually changed, so a click that
// moves nothing leaves no trace.
//
// Undo is snapshot-based rather than a stack of inverse operations: the
// document is a small pure-JSON object, cloning it costs microseconds, and a
// snapshot cannot be subtly wrong the way a hand-written inverse can. The
// stack is capped so a long session cannot grow without bound.
window.Canvas = window.Canvas || {};
Canvas.Doc = (function () {
var MAX_UNDO = 100;
var doc = { nodes: [], edges: [] };
var undoStack = [];
var redoStack = [];
var pending = null;
var dirty = false;
var version = '';
var listeners = [];
function clone(v) { return JSON.parse(JSON.stringify(v)); }
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
// newId mints a 16-hex-character node/edge id, the shape the format uses in
// the wild. Collisions are checked against the live document anyway.
function newId() {
var bytes = new Uint8Array(8);
window.crypto.getRandomValues(bytes);
var out = '';
for (var i = 0; i < bytes.length; i++) {
out += (bytes[i] + 0x100).toString(16).slice(1);
}
if (find(doc.nodes, out) || find(doc.edges, out)) return newId();
return out;
}
function find(list, id) {
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
return {
init: function (d, hash) {
doc = (d && typeof d === 'object') ? d : {};
if (!Array.isArray(doc.nodes)) doc.nodes = [];
if (!Array.isArray(doc.edges)) doc.edges = [];
undoStack = [];
redoStack = [];
pending = null;
dirty = false;
version = hash || '';
},
get: function () { return doc; },
nodes: function () { return doc.nodes; },
edges: function () { return doc.edges; },
node: function (id) { return find(doc.nodes, id); },
edge: function (id) { return find(doc.edges, id); },
newId: newId,
// edgesFor returns every edge touching any of the given node ids.
edgesFor: function (nodeIds) {
var want = {};
nodeIds.forEach(function (id) { want[id] = true; });
return doc.edges.filter(function (e) {
return want[e.fromNode] || want[e.toNode];
});
},
// --- gesture bracketing ---
//
// Nested begin() calls join the outer gesture rather than starting a
// new one, so a composite action (drag a group, which drags its
// members) still produces exactly one undo entry.
begin: function (label) {
if (pending) { pending.depth++; return; }
pending = { label: label || '', before: clone(doc), depth: 0 };
},
commit: function () {
if (!pending) return;
if (pending.depth > 0) { pending.depth--; return; }
var before = pending.before;
var label = pending.label;
pending = null;
// A gesture that changed nothing (a click that selected without
// moving) must not push an undo entry the user would have to press
// Ctrl+Z through.
if (JSON.stringify(before) === JSON.stringify(doc)) return;
undoStack.push({ label: label, doc: before });
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack.length = 0;
dirty = true;
emit();
},
// abort rolls the document back to the gesture's starting state, for
// Escape mid-drag.
abort: function () {
if (!pending) return;
if (pending.depth > 0) { pending.depth--; return; }
doc = pending.before;
pending = null;
emit();
},
inGesture: function () { return pending !== null; },
undo: function () {
if (!undoStack.length) return false;
var entry = undoStack.pop();
redoStack.push({ label: entry.label, doc: clone(doc) });
doc = entry.doc;
dirty = true;
emit();
return true;
},
redo: function () {
if (!redoStack.length) return false;
var entry = redoStack.pop();
undoStack.push({ label: entry.label, doc: clone(doc) });
doc = entry.doc;
dirty = true;
emit();
return true;
},
canUndo: function () { return undoStack.length > 0; },
canRedo: function () { return redoStack.length > 0; },
// --- structural helpers ---
addNode: function (node) {
doc.nodes.push(node);
return node;
},
addEdge: function (edge) {
doc.edges.push(edge);
return edge;
},
// remove drops the named nodes and edges. Every edge touching a removed
// node goes too — a dangling endpoint would fail validation on save, so
// it can never be allowed to exist even transiently.
remove: function (nodeIds, edgeIds) {
var deadNodes = {};
(nodeIds || []).forEach(function (id) { deadNodes[id] = true; });
var deadEdges = {};
(edgeIds || []).forEach(function (id) { deadEdges[id] = true; });
doc.nodes = doc.nodes.filter(function (n) { return !deadNodes[n.id]; });
doc.edges = doc.edges.filter(function (e) {
return !deadEdges[e.id] && !deadNodes[e.fromNode] && !deadNodes[e.toNode];
});
},
// --- save state ---
isDirty: function () { return dirty; },
markSaved: function (hash) {
dirty = false;
if (hash) version = hash;
emit();
},
version: function () { return version; },
onChange: function (fn) { listeners.push(fn); },
notify: emit
};
})();
+237
View File
@@ -0,0 +1,237 @@
// edges.js — the SVG edge layer.
//
// Edges are cubic beziers anchored at the midpoint of a node side, with control
// points pushed out along that side's normal. When a side is unspecified the
// dominant axis between the two centres picks one, which keeps auto-routed
// edges predictable instead of hunting for a global minimum.
//
// The SVG sits inside the transformed world and draws in world coordinates, so
// it pans and zooms with the nodes for free.
window.Canvas = window.Canvas || {};
Canvas.Edges = (function () {
var Doc = Canvas.Doc;
var Select = Canvas.Select;
var SVGNS = 'http://www.w3.org/2000/svg';
var ARROW = 11;
var svg = null;
var groups = {}; // edge id -> <g>
var temp = null; // the rubber-band path while drawing a new edge
function el(name, attrs) {
var node = document.createElementNS(SVGNS, name);
for (var k in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]);
}
return node;
}
// sideAnchor returns the connection point on a node side together with that
// side's outward normal, which the control points and arrowheads both need.
function sideAnchor(n, side) {
switch (side) {
case 'top': return { x: n.x + n.width / 2, y: n.y, nx: 0, ny: -1 };
case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height, nx: 0, ny: 1 };
case 'left': return { x: n.x, y: n.y + n.height / 2, nx: -1, ny: 0 };
default: return { x: n.x + n.width, y: n.y + n.height / 2, nx: 1, ny: 0 };
}
}
// autoSides picks the facing sides for an edge with no explicit ones: the
// dominant axis between the node centres wins. Simple and stable — the edge
// does not flip sides every time a node moves a few pixels.
function autoSides(a, b) {
var dx = (b.x + b.width / 2) - (a.x + a.width / 2);
var dy = (b.y + b.height / 2) - (a.y + a.height / 2);
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0 ? ['right', 'left'] : ['left', 'right'];
}
return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom'];
}
function geometry(e) {
var a = Doc.node(e.fromNode);
var b = Doc.node(e.toNode);
if (!a || !b) return null;
var auto = autoSides(a, b);
var fromSide = e.fromSide || auto[0];
// A self-loop has no meaningful "facing" side, so it gets a fixed pair
// that always produces a visible arc out of and back into the node.
var toSide = e.toSide || (a === b ? 'top' : auto[1]);
var p0 = sideAnchor(a, fromSide);
var p3 = sideAnchor(b, toSide);
var dist = Math.sqrt((p3.x - p0.x) * (p3.x - p0.x) + (p3.y - p0.y) * (p3.y - p0.y));
var k = Math.max(40, dist / 3);
return {
p0: p0,
p3: p3,
c1: { x: p0.x + p0.nx * k, y: p0.y + p0.ny * k },
c2: { x: p3.x + p3.nx * k, y: p3.y + p3.ny * k }
};
}
function curve(g) {
return 'M' + g.p0.x + ',' + g.p0.y +
'C' + g.c1.x + ',' + g.c1.y + ' ' + g.c2.x + ',' + g.c2.y +
' ' + g.p3.x + ',' + g.p3.y;
}
// midpoint of a cubic at t=0.5, closed form — no DOM measurement needed to
// place a label.
function midpoint(g) {
return {
x: (g.p0.x + 3 * g.c1.x + 3 * g.c2.x + g.p3.x) / 8,
y: (g.p0.y + 3 * g.c1.y + 3 * g.c2.y + g.p3.y) / 8
};
}
// arrowhead draws a triangle with its tip at pt, its base offset along the
// outward normal — so the arrow points into the node it terminates on.
function arrowhead(pt) {
var bx = pt.x + pt.nx * ARROW;
var by = pt.y + pt.ny * ARROW;
var px = -pt.ny * ARROW * 0.42;
var py = pt.nx * ARROW * 0.42;
return 'M' + pt.x + ',' + pt.y +
'L' + (bx + px) + ',' + (by + py) +
'L' + (bx - px) + ',' + (by - py) + 'Z';
}
function colorVar(c) {
if (!c) return 'var(--canvas-edge)';
if (c.charAt(0) === '#') return c;
return 'var(--canvas-' + c + ')';
}
function build(e) {
var g = el('g', { 'class': 'canvas-edge' });
g.dataset.id = e.id;
// A transparent fat stroke under the visible line is the hit target:
// a 2px curve is essentially unclickable otherwise.
g.appendChild(el('path', { 'class': 'canvas-edge-hit', fill: 'none' }));
g.appendChild(el('path', { 'class': 'canvas-edge-line', fill: 'none' }));
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-to' }));
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-from' }));
var label = el('text', { 'class': 'canvas-edge-label', 'text-anchor': 'middle', 'dominant-baseline': 'middle' });
g.appendChild(label);
return g;
}
function update(g, e) {
var geo = geometry(e);
if (!geo) { g.setAttribute('display', 'none'); return; }
g.removeAttribute('display');
var d = curve(geo);
var stroke = colorVar(e.color);
g.querySelector('.canvas-edge-hit').setAttribute('d', d);
var line = g.querySelector('.canvas-edge-line');
line.setAttribute('d', d);
line.setAttribute('stroke', stroke);
// Spec defaults: fromEnd is "none", toEnd is "arrow".
var toHead = g.querySelector('.canvas-edge-head-to');
if ((e.toEnd || 'arrow') === 'arrow') {
toHead.setAttribute('d', arrowhead(geo.p3));
toHead.setAttribute('fill', stroke);
toHead.removeAttribute('display');
} else {
toHead.setAttribute('display', 'none');
}
var fromHead = g.querySelector('.canvas-edge-head-from');
if ((e.fromEnd || 'none') === 'arrow') {
fromHead.setAttribute('d', arrowhead(geo.p0));
fromHead.setAttribute('fill', stroke);
fromHead.removeAttribute('display');
} else {
fromHead.setAttribute('display', 'none');
}
var label = g.querySelector('.canvas-edge-label');
if (e.label) {
var m = midpoint(geo);
label.setAttribute('x', m.x);
label.setAttribute('y', m.y);
label.textContent = e.label;
label.removeAttribute('display');
} else {
label.setAttribute('display', 'none');
}
g.classList.toggle('is-selected', Select.hasEdge(e.id));
}
return {
init: function (svgEl) { svg = svgEl; },
sideAnchor: sideAnchor,
autoSides: autoSides,
render: function () {
var seen = {};
Doc.edges().forEach(function (e) {
seen[e.id] = true;
var g = groups[e.id];
if (!g) {
g = build(e);
groups[e.id] = g;
svg.appendChild(g);
}
update(g, e);
});
Object.keys(groups).forEach(function (id) {
if (seen[id]) return;
groups[id].remove();
delete groups[id];
});
},
// syncFor repaints only the edges touching the given nodes. Called on
// every frame of a drag, where re-running the full reconcile would be
// pure overhead.
syncFor: function (nodeIds) {
Doc.edgesFor(nodeIds).forEach(function (e) {
var g = groups[e.id];
if (g) update(g, e);
});
},
syncSelection: function () {
Doc.edges().forEach(function (e) {
var g = groups[e.id];
if (g) g.classList.toggle('is-selected', Select.hasEdge(e.id));
});
},
// --- rubber band shown while dragging a new edge out of a port ---
beginTemp: function () {
if (temp) temp.remove();
temp = el('path', { 'class': 'canvas-edge-temp', fill: 'none' });
svg.appendChild(temp);
},
updateTemp: function (node, side, to) {
if (!temp) return;
var p0 = sideAnchor(node, side);
var dist = Math.sqrt((to.x - p0.x) * (to.x - p0.x) + (to.y - p0.y) * (to.y - p0.y));
var k = Math.max(40, dist / 3);
temp.setAttribute('d',
'M' + p0.x + ',' + p0.y +
'C' + (p0.x + p0.nx * k) + ',' + (p0.y + p0.ny * k) +
' ' + to.x + ',' + to.y + ' ' + to.x + ',' + to.y);
},
endTemp: function () {
if (!temp) return;
temp.remove();
temp = null;
}
};
})();
+204
View File
@@ -0,0 +1,204 @@
// edit.js — editing a node's content.
//
// Text nodes get the vendored CodeMirror editor in place, positioned over the
// node in *screen* coordinates rather than mounted inside the zoomed world.
// CodeMirror measures its own layout in device pixels, so a CSS-scaled parent
// wrecks caret placement and selection maths; a fixed-position panel sidesteps
// that entirely and stays sharp at every zoom level.
//
// The other node types have nothing to type into, so they reuse the existing
// tree picker and modal instead of an editor.
window.Canvas = window.Canvas || {};
Canvas.Edit = (function () {
var Doc = Canvas.Doc;
var View = Canvas.View;
var MIN_WIDTH = 360;
var MIN_HEIGHT = 200;
var panel = null;
var view = null;
var currentId = null;
var original = '';
function destroy() {
if (view) { view.destroy(); view = null; }
if (panel) { panel.remove(); panel = null; }
currentId = null;
original = '';
document.removeEventListener('pointerdown', onOutside, true);
}
function commit() {
if (!panel || !view) return;
var text = view.state.doc.toString();
var id = currentId;
destroy();
var n = Doc.node(id);
if (!n || text === original) return;
Doc.begin('edit text');
n.text = text;
Doc.commit();
}
function onOutside(e) {
if (!panel || panel.contains(e.target)) return;
commit();
}
// screenRect places the editor over the node it edits, clamped to a usable
// minimum and kept inside the viewport — a 60x60 node still needs a panel
// big enough to type in.
function screenRect(n) {
var s = View.scale();
var tl = View.toScreen(n.x, n.y);
var w = Math.max(MIN_WIDTH, n.width * s);
var h = Math.max(MIN_HEIGHT, n.height * s);
w = Math.min(w, window.innerWidth - 16);
h = Math.min(h, window.innerHeight - 16);
var left = Math.max(8, Math.min(tl.x, window.innerWidth - w - 8));
var top = Math.max(8, Math.min(tl.y, window.innerHeight - h - 8));
return { left: left, top: top, width: w, height: h };
}
function openText(n) {
var rect = screenRect(n);
panel = document.createElement('div');
panel.className = 'canvas-editor panel panel-floating';
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.width = rect.width + 'px';
panel.style.height = rect.height + 'px';
var hint = document.createElement('div');
hint.className = 'panel-header';
hint.textContent = 'Ctrl+Enter or click away to apply · Escape to cancel';
panel.appendChild(hint);
var mount = document.createElement('div');
mount.className = 'canvas-editor-mount';
panel.appendChild(mount);
document.body.appendChild(panel);
currentId = n.id;
original = n.text || '';
view = new CM.EditorView({
state: CM.EditorState.create({
doc: original,
selection: { anchor: original.length },
extensions: [
CM.history(),
CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
CM.markdown({ base: CM.markdownLanguage }),
CM.syntaxHighlighting(CM.highlightStyle),
CM.closeBrackets(),
CM.autocompletion({ override: [WikiComplete.source] }),
CM.theme,
// Mod-Enter goes first (nothing else claims it); Escape goes
// last so an open completion popup still gets first refusal
// on it and closes itself instead of discarding the edit.
CM.keymap.of([{ key: 'Mod-Enter', run: function () { commit(); return true; } }]
.concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab],
[{ key: 'Escape', run: function () { destroy(); return true; } }]
))
]
}),
parent: mount
});
view.focus();
// Capture phase: a click on a toolbar button should apply the edit
// before that button's own handler reads the document.
document.addEventListener('pointerdown', onOutside, true);
}
function openFile(n) {
var start = (n.file || '/').replace(/\/+$/, '') || '/';
openTreePicker({
title: 'File node — pick a target',
mode: 'any',
initialPath: start,
preselect: start,
confirmLabel: 'USE',
onSelect: function (p) {
if (!p || p === n.file) return;
Doc.begin('retarget file node');
n.file = p;
n.subpath = '';
Doc.commit();
}
});
}
function promptValue(title, label, value, onValue) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = label;
input.value = value || '';
openModal({
title: title,
body: input,
confirm: {
label: 'OK',
onConfirm: function () {
closeModal();
onValue(input.value.trim());
}
}
});
}
function openLink(n) {
promptValue('Link node', 'https://example.com', n.url, function (v) {
if (!v || v === n.url) return;
Doc.begin('edit link');
n.url = v;
Doc.commit();
});
}
function openGroup(n) {
promptValue('Group label', 'Label', n.label, function (v) {
if (v === (n.label || '')) return;
Doc.begin('edit group label');
if (v) n.label = v; else delete n.label;
Doc.commit();
});
}
// close applies whatever is currently open, if anything.
function close() {
if (panel) commit();
}
return {
open: function (id) {
var n = Doc.node(id);
if (!n) return;
close();
switch (n.type) {
case 'text': openText(n); break;
case 'file': openFile(n); break;
case 'link': openLink(n); break;
case 'group': openGroup(n); break;
}
},
// Called from the gesture layer on any pointerdown on the surface, so
// starting a drag never silently drops an edit.
close: close,
isOpen: function () { return panel !== null; }
};
})();
+461
View File
@@ -0,0 +1,461 @@
// 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; }
};
})();
+88
View File
@@ -0,0 +1,88 @@
{{define "headerActions"}}
<a class="btn" href="{{.PostURL}}" title="Back to the page">PAGE</a>
<span class="dropdown">
<button type="button" class="btn dropdown-toggle" data-action="canvas-menu" title="Canvas actions">CANVAS▾</button>
<div class="dropdown-menu align-right">
<button type="button" class="btn btn-block" data-action="canvas-rename">Rename…</button>
<button type="button" class="btn btn-block danger" data-action="canvas-delete">Delete canvas</button>
</div>
</span>
<button class="btn" type="button" data-action="canvas-save" title="Save (Ctrl+S)">SAVE</button>
{{end}}
{{define "content"}}
<script>
// Full-bleed view mode: the canvas surface owns the viewport instead of the
// centred reading column. Set here rather than server-side for the same
// reason the editor does it — one class, no extra template plumbing.
document.body.classList.add('canvas-mode');
</script>
{{if .CanvasError}}
<div class="content">
<h1>{{.ActiveCanvas}}</h1>
<p class="canvas-broken">This canvas could not be opened — {{.CanvasError}}</p>
<p>Fix it in the file itself, then reload.
<a href="{{.ActiveCanvas}}.canvas?raw">view the raw file</a> ·
<a href="{{.PostURL}}">back to the page</a></p>
</div>
{{else}}
<div class="canvas-app">
<nav class="canvas-tabs row" aria-label="Page views">
<a class="canvas-tab" href="{{.PostURL}}">PAGE</a>
{{range .Canvases}}<a class="canvas-tab{{if .Active}} is-active{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
</nav>
<div class="canvas-toolbar row">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo (Ctrl+Z)" disabled>↶</button>
<button type="button" class="btn btn-tool" data-action="redo" title="Redo (Ctrl+Shift+Z)" disabled>↷</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="add-text" title="Add text node (T)">TEXT</button>
<button type="button" class="btn btn-tool" data-action="add-file" title="Add file node (F)">FILE</button>
<button type="button" class="btn btn-tool" data-action="add-link" title="Add link node (L)">LINK</button>
<button type="button" class="btn btn-tool" data-action="add-group" title="Add group (G)">GROUP</button>
<span class="toolbar-sep"></span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" data-action="color-menu" title="Colour of the selection">■▾</button>
<div class="dropdown-menu canvas-color-menu">
<button type="button" class="btn btn-tool btn-block" data-color="">Default</button>
<button type="button" class="btn btn-tool btn-block" data-color="1"><span class="canvas-swatch" data-swatch="1"></span> Red</button>
<button type="button" class="btn btn-tool btn-block" data-color="2"><span class="canvas-swatch" data-swatch="2"></span> Orange</button>
<button type="button" class="btn btn-tool btn-block" data-color="3"><span class="canvas-swatch" data-swatch="3"></span> Yellow</button>
<button type="button" class="btn btn-tool btn-block" data-color="4"><span class="canvas-swatch" data-swatch="4"></span> Green</button>
<button type="button" class="btn btn-tool btn-block" data-color="5"><span class="canvas-swatch" data-swatch="5"></span> Cyan</button>
<button type="button" class="btn btn-tool btn-block" data-color="6"><span class="canvas-swatch" data-swatch="6"></span> Purple</button>
</div>
</span>
<button type="button" class="btn btn-tool" data-action="delete-selection" title="Delete selection (Del)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="zoom-out" title="Zoom out (−)">−</button>
<button type="button" class="btn btn-tool" data-action="zoom-reset" title="Reset zoom to 100% (0)"><span data-canvas-zoom>100%</span></button>
<button type="button" class="btn btn-tool" data-action="zoom-in" title="Zoom in (+)">+</button>
<button type="button" class="btn btn-tool" data-action="fit" title="Fit to content (9)">FIT</button>
<span class="canvas-status muted" data-canvas-status></span>
</div>
<div class="canvas-surface" id="canvas-surface" tabindex="0">
<div class="canvas-world" id="canvas-world">
<div class="canvas-layer" id="canvas-groups"></div>
<svg class="canvas-edges" id="canvas-edges" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="canvas-layer" id="canvas-nodes"></div>
</div>
<div class="canvas-marquee" id="canvas-marquee" hidden></div>
<div class="canvas-hint muted" data-canvas-hint hidden>
Empty canvas — use the toolbar to add a node. Drag the background to pan, ctrl+wheel to zoom.
</div>
</div>
</div>
<script>window.CANVAS_DATA = {{.CanvasJSON}};</script>
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
<script src="/_/editor/wikicomplete.js"></script>
<script src="/_/canvas/doc.js"></script>
<script src="/_/canvas/view.js"></script>
<script src="/_/canvas/select.js"></script>
<script src="/_/canvas/nodes.js"></script>
<script src="/_/canvas/edges.js"></script>
<script src="/_/canvas/interact.js"></script>
<script src="/_/canvas/edit.js"></script>
<script src="/_/canvas/save.js"></script>
<script src="/_/canvas/main.js"></script>
{{end}}
{{end}}
+389
View File
@@ -0,0 +1,389 @@
// main.js — bootstrap, toolbar, and keyboard shortcuts.
//
// The payload the server inlined (document + pre-rendered node HTML + the
// version token) is the only input; nothing else is fetched on load.
(function () {
var data = window.CANVAS_DATA;
var surface = document.getElementById('canvas-surface');
if (!data || !surface) return;
var Doc = Canvas.Doc;
var View = Canvas.View;
var Select = Canvas.Select;
var Nodes = Canvas.Nodes;
var Edges = Canvas.Edges;
var Save = Canvas.Save;
var world = document.getElementById('canvas-world');
var marquee = document.getElementById('canvas-marquee');
var hint = document.querySelector('[data-canvas-hint]');
var statusEl = document.querySelector('[data-canvas-status]');
var zoomEl = document.querySelector('[data-canvas-zoom]');
// Default sizes for new nodes, in world pixels. Multiples of the 20px grid
// so a fresh node lands aligned.
var SIZES = {
text: { width: 260, height: 120 },
file: { width: 400, height: 400 },
link: { width: 400, height: 140 },
group: { width: 520, height: 400 }
};
function snap(v) { return Math.round(v / View.GRID) * View.GRID; }
// --- document actions ---
Canvas.Actions = {
// add drops a node centred on a world point (the viewport centre when
// called from the toolbar, the cursor when called from a double-click).
add: function (type, extra, at) {
var size = SIZES[type];
var p = at || View.centreOfViewport();
var node = {
id: Doc.newId(),
type: type,
x: snap(p.x - size.width / 2),
y: snap(p.y - size.height / 2),
width: size.width,
height: size.height
};
for (var k in extra) {
if (Object.prototype.hasOwnProperty.call(extra, k)) node[k] = extra[k];
}
Doc.begin('add ' + type);
Doc.addNode(node);
Doc.commit();
Select.set([node.id], []);
return node;
},
addText: function (at) {
return Canvas.Actions.add('text', { text: '' }, at);
},
addGroup: function (at) {
return Canvas.Actions.add('group', { label: 'Group' }, at);
},
// A file node is meaningless without a target and a link node without a
// url, so both ask first and only then create — validation on the server
// rejects the empty forms anyway.
addFile: function () {
openTreePicker({
title: 'File node — pick a target',
mode: 'any',
initialPath: data.postUrl,
confirmLabel: 'ADD',
onSelect: function (p) {
if (p) Canvas.Actions.add('file', { file: p });
}
});
},
addLink: function () {
var input = document.createElement('input');
input.type = 'url';
input.className = 'input';
input.placeholder = 'https://example.com';
openModal({
title: 'Link node',
body: input,
confirm: {
label: 'ADD',
onConfirm: function () {
var v = input.value.trim();
if (!v) return;
closeModal();
Canvas.Actions.add('link', { url: v });
}
}
});
},
deleteSelection: function () {
if (Select.isEmpty()) return;
Doc.begin('delete');
Doc.remove(Select.nodes(), Select.edges());
Doc.commit();
Select.clear();
},
setColor: function (color) {
if (Select.isEmpty()) return;
Doc.begin('colour');
Select.nodes().forEach(function (id) {
var n = Doc.node(id);
if (!n) return;
if (color) n.color = color; else delete n.color;
});
Select.edges().forEach(function (id) {
var e = Doc.edge(id);
if (!e) return;
if (color) e.color = color; else delete e.color;
});
Doc.commit();
},
// nudge moves the selection by whole grid steps, or single pixels with
// shift held for the cases where the grid is in the way.
nudge: function (dx, dy, fine) {
var ids = Select.nodes();
if (!ids.length) return;
var step = fine ? 1 : View.GRID;
Doc.begin('nudge');
ids.forEach(function (id) {
var n = Doc.node(id);
if (!n) return;
n.x += dx * step;
n.y += dy * step;
});
Doc.commit();
},
selectAll: function () {
Select.set(
Doc.nodes().map(function (n) { return n.id; }),
Doc.edges().map(function (e) { return e.id; })
);
}
};
// --- chrome ---
function setStatus(text) {
if (statusEl) statusEl.textContent = text || '';
}
function updateToolbar() {
var undoBtn = document.querySelector('[data-action="undo"]');
var redoBtn = document.querySelector('[data-action="redo"]');
var delBtn = document.querySelector('[data-action="delete-selection"]');
if (undoBtn) undoBtn.disabled = !Doc.canUndo();
if (redoBtn) redoBtn.disabled = !Doc.canRedo();
if (delBtn) delBtn.disabled = Select.isEmpty();
var saveBtn = document.querySelector('[data-action="canvas-save"]');
if (saveBtn) saveBtn.classList.toggle('is-active', Doc.isDirty());
if (hint) hint.hidden = Doc.nodes().length > 0;
}
function updateZoom() {
if (zoomEl) zoomEl.textContent = Math.round(View.scale() * 100) + '%';
}
// refresh is the single re-render path: prune the selection against the
// (possibly undone) document, reconcile both layers, update the chrome.
function refresh() {
Select.prune(Doc.get());
Nodes.render();
Edges.render();
updateToolbar();
}
// --- canvas-level actions (rename / delete the file itself) ---
function leaveGuard() {
if (!Doc.isDirty()) return true;
return window.confirm('This canvas has unsaved changes. Leave anyway?');
}
function canvasAction(url, then) {
fetch(url, { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
.then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) { then(); return; }
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
})
.catch(function () { alert('Network error'); });
}
function renameCanvas() {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.value = data.name;
openModal({
title: 'Rename canvas',
body: input,
confirm: {
label: 'RENAME',
onConfirm: function () {
var v = input.value.trim();
if (!v || v === data.name) { closeModal(); return; }
closeModal();
canvasAction(
data.postUrl + '?canvas=' + encodeURIComponent(data.name) +
'&rename=' + encodeURIComponent(v),
function () {
window.location.href = data.postUrl + '?canvas=' + encodeURIComponent(v);
}
);
}
}
});
}
function deleteCanvas() {
openModal({
title: 'Delete canvas',
body: 'Delete "' + data.name + '"? The page itself is not affected.',
confirm: {
label: 'DELETE',
danger: true,
onConfirm: function () {
closeModal();
canvasAction(
data.postUrl + '?canvas=' + encodeURIComponent(data.name) + '&delete=1',
function () { window.location.href = data.postUrl; }
);
}
}
});
}
// --- wiring ---
function wireToolbar() {
var handlers = {
'undo': function () { Doc.undo(); },
'redo': function () { Doc.redo(); },
'add-text': function () { Canvas.Edit.open(Canvas.Actions.addText().id); },
'add-file': function () { Canvas.Actions.addFile(); },
'add-link': function () { Canvas.Actions.addLink(); },
'add-group': function () { Canvas.Actions.addGroup(); },
'delete-selection': function () { Canvas.Actions.deleteSelection(); },
'zoom-in': function () { View.zoomBy(1.2); },
'zoom-out': function () { View.zoomBy(1 / 1.2); },
'zoom-reset': function () { View.resetZoom(); },
'fit': function () { View.fit(Doc.nodes()); },
'canvas-save': function () { Save.run(); },
'canvas-rename': renameCanvas,
'canvas-delete': deleteCanvas
};
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var fn = handlers[btn.dataset.action];
if (!fn) return;
e.preventDefault();
fn();
});
document.querySelectorAll('.canvas-color-menu [data-color]').forEach(function (btn) {
btn.addEventListener('click', function () {
Canvas.Actions.setColor(btn.dataset.color);
});
});
// The header dropdowns are wired on demand: the auto-wiring in
// global-shortcuts.js only covers the page's ACTIONS menu, which this
// view replaces.
['canvas-menu', 'color-menu'].forEach(function (action) {
wireDropdown(document.querySelector('[data-action="' + action + '"]'));
});
document.querySelectorAll('.canvas-tabs a').forEach(function (a) {
a.addEventListener('click', function (e) {
if (!leaveGuard()) e.preventDefault();
});
});
}
// typing reports whether the keystroke belongs to a text field, an open
// modal, or the node editor — in which case the canvas must keep its hands
// off it.
function typing(e) {
var t = e.target;
if (!t || !t.closest) return false;
return !!t.closest('input, textarea, select, .cm-editor, .modal, [contenteditable="true"]');
}
function wireKeys() {
document.addEventListener('keydown', function (e) {
if (typing(e)) return;
var mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault();
Save.run();
return;
}
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) Doc.redo(); else Doc.undo();
return;
}
if (mod && e.key.toLowerCase() === 'y') {
e.preventDefault();
Doc.redo();
return;
}
if (mod && e.key.toLowerCase() === 'a') {
e.preventDefault();
Canvas.Actions.selectAll();
return;
}
if (mod) return;
switch (e.key) {
case 'Delete':
case 'Backspace':
e.preventDefault();
Canvas.Actions.deleteSelection();
break;
case 'Escape':
if (!Canvas.Interact.cancel()) Select.clear();
break;
case 'Enter':
var sel = Select.nodes();
if (sel.length === 1) {
e.preventDefault();
Canvas.Edit.open(sel[0]);
}
break;
case 'ArrowUp': e.preventDefault(); Canvas.Actions.nudge(0, -1, e.shiftKey); break;
case 'ArrowDown': e.preventDefault(); Canvas.Actions.nudge(0, 1, e.shiftKey); break;
case 'ArrowLeft': e.preventDefault(); Canvas.Actions.nudge(-1, 0, e.shiftKey); break;
case 'ArrowRight': e.preventDefault(); Canvas.Actions.nudge(1, 0, e.shiftKey); break;
case 't': Canvas.Edit.open(Canvas.Actions.addText().id); break;
case 'f': Canvas.Actions.addFile(); break;
case 'l': Canvas.Actions.addLink(); break;
case 'g': Canvas.Actions.addGroup(); break;
case '9': View.fit(Doc.nodes()); break;
case '0': View.resetZoom(); break;
case '+':
case '=': View.zoomBy(1.2); break;
case '-': View.zoomBy(1 / 1.2); break;
}
});
}
// --- boot ---
Doc.init(data.doc, data.hash);
Nodes.seed(data.rendered, Doc.get());
Nodes.init(document.getElementById('canvas-nodes'), document.getElementById('canvas-groups'));
Edges.init(document.getElementById('canvas-edges'));
View.init(surface, world);
Canvas.Interact.init(surface, marquee);
Save.init(data.postUrl, data.name);
Doc.onChange(refresh);
Select.onChange(function () {
Nodes.syncSelection();
Edges.syncSelection();
updateToolbar();
});
View.onChange(updateZoom);
Save.onState(function (state, message) { setStatus(message); });
wireToolbar();
wireKeys();
refresh();
View.fit(Doc.nodes());
updateZoom();
surface.focus();
})();
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
// save.js — writing the canvas back.
//
// The whole document goes up in one request: a canvas is a single JSON object
// whose edges reference node ids, so a partial patch would have to reason about
// cross-references the server would then have to re-validate anyway.
//
// Two server rejections are worth handling rather than dumping on the user:
// a 409 (someone else changed the file) and the empty-canvas guard (which asks
// for explicit confirmation before letting a save blank a populated canvas).
window.Canvas = window.Canvas || {};
Canvas.Save = (function () {
var Doc = Canvas.Doc;
var action = '';
var saving = false;
var listeners = [];
function emit(state, message) {
for (var i = 0; i < listeners.length; i++) listeners[i](state, message);
}
function post(url) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-Canvas-Version': Doc.version()
},
body: JSON.stringify(Doc.get())
});
}
function run(force) {
if (saving) return Promise.resolve(false);
if (!Doc.isDirty() && !force) {
emit('clean', 'no changes');
return Promise.resolve(true);
}
saving = true;
emit('saving', 'saving…');
var url = action + (force ? '&empty=1' : '');
return post(url).then(function (res) {
if (res.status === 204) {
Doc.markSaved(res.headers.get('X-Canvas-Version') || '');
emit('clean', 'saved');
return true;
}
return res.text().then(function (msg) {
if (res.status === 409) {
conflict(msg);
} else if (res.status === 400 && msg.indexOf('empty canvas') !== -1) {
confirmEmpty(msg);
} else {
emit('error', msg || ('save failed (' + res.status + ')'));
alert(msg || ('Save failed (' + res.status + ')'));
}
return false;
});
}).catch(function () {
emit('error', 'network error');
alert('Could not reach the server — your changes are still here, try again.');
return false;
}).finally(function () {
saving = false;
});
}
// conflict: the file changed underneath us. Overwriting silently would
// discard whatever the other writer did, so the choice is the user's — and
// reloading would discard *their* work, so that is spelled out too.
function conflict(msg) {
emit('error', 'conflict');
openModal({
title: 'Canvas changed on disk',
body: msg + '\n\nReload to take the version on disk (your unsaved changes here are lost), ' +
'or keep editing and save again to overwrite it.',
confirm: {
label: 'RELOAD',
danger: true,
onConfirm: function () {
closeModal();
window.location.reload();
}
},
cancel: { label: 'KEEP EDITING', autofocus: true }
});
}
function confirmEmpty(msg) {
emit('error', 'refused');
openModal({
title: 'Save an empty canvas?',
body: msg + '\n\nThis removes every node from the file.',
confirm: {
label: 'EMPTY IT',
danger: true,
onConfirm: function () {
closeModal();
run(true);
}
}
});
}
return {
init: function (postURL, name) {
action = postURL + '?canvas=' + encodeURIComponent(name);
// Unsaved work must not vanish on a stray Back or tab close. The
// browser shows its own generic wording; the return value is only a
// signal that there is something to lose.
window.addEventListener('beforeunload', function (e) {
if (!Doc.isDirty()) return;
e.preventDefault();
e.returnValue = '';
});
},
run: function () { return run(false); },
isSaving: function () { return saving; },
onState: function (fn) { listeners.push(fn); }
};
})();
+77
View File
@@ -0,0 +1,77 @@
// select.js — which nodes and edges are selected.
//
// Kept apart from the document so selection never lands in the undo stack or
// on disk: undoing an edit should restore geometry, not re-select whatever
// happened to be highlighted at the time.
window.Canvas = window.Canvas || {};
Canvas.Select = (function () {
var nodes = {};
var edges = {};
var listeners = [];
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
function keys(obj) { return Object.keys(obj); }
return {
nodes: function () { return keys(nodes); },
edges: function () { return keys(edges); },
hasNode: function (id) { return !!nodes[id]; },
hasEdge: function (id) { return !!edges[id]; },
isEmpty: function () { return !keys(nodes).length && !keys(edges).length; },
count: function () { return keys(nodes).length + keys(edges).length; },
set: function (nodeIds, edgeIds) {
nodes = {};
edges = {};
(nodeIds || []).forEach(function (id) { nodes[id] = true; });
(edgeIds || []).forEach(function (id) { edges[id] = true; });
emit();
},
addNode: function (id) {
if (nodes[id]) return;
nodes[id] = true;
emit();
},
toggleNode: function (id) {
if (nodes[id]) delete nodes[id]; else nodes[id] = true;
emit();
},
toggleEdge: function (id) {
if (edges[id]) delete edges[id]; else edges[id] = true;
emit();
},
clear: function () {
if (!keys(nodes).length && !keys(edges).length) return;
nodes = {};
edges = {};
emit();
},
// prune drops ids that no longer exist. Called after undo/redo, where
// the document can lose nodes the selection still refers to.
prune: function (doc) {
var changed = false;
var live = {};
doc.nodes.forEach(function (n) { live[n.id] = true; });
keys(nodes).forEach(function (id) {
if (!live[id]) { delete nodes[id]; changed = true; }
});
var liveEdges = {};
doc.edges.forEach(function (e) { liveEdges[e.id] = true; });
keys(edges).forEach(function (id) {
if (!liveEdges[id]) { delete edges[id]; changed = true; }
});
if (changed) emit();
},
onChange: function (fn) { listeners.push(fn); }
};
})();
+147
View File
@@ -0,0 +1,147 @@
// view.js — pan and zoom.
//
// The whole scene lives in one transformed <div>, so panning and zooming cost a
// single CSS transform: no relayout, no re-render of node content, nothing that
// touches the DOM per node. That is what keeps a few hundred nodes usable on
// the mobile/VPN path.
//
// The dot grid is painted on the (untransformed) surface and its size/offset
// follow the transform, which keeps it crisp at every zoom level instead of
// scaling a bitmap.
window.Canvas = window.Canvas || {};
Canvas.View = (function () {
var MIN_SCALE = 0.05;
var MAX_SCALE = 4;
var GRID = 20;
var surface = null;
var world = null;
var tx = 0, ty = 0, scale = 1;
var listeners = [];
function apply() {
world.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
var step = GRID * scale;
surface.style.backgroundSize = step + 'px ' + step + 'px';
surface.style.backgroundPosition = tx + 'px ' + ty + 'px';
// Below a few pixels apart the dots stop reading as a grid and start
// reading as noise, so zooming far out drops them entirely.
surface.classList.toggle('is-grid-hidden', step < 6);
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
function clampScale(s) {
return Math.max(MIN_SCALE, Math.min(MAX_SCALE, s));
}
// zoomAt scales about a screen point, so the world coordinate under the
// cursor stays put — the behaviour that makes wheel-zoom feel anchored
// rather than lurching toward the origin.
function zoomAt(clientX, clientY, factor) {
var next = clampScale(scale * factor);
if (next === scale) return;
var r = surface.getBoundingClientRect();
var px = clientX - r.left;
var py = clientY - r.top;
var ratio = next / scale;
tx = px - (px - tx) * ratio;
ty = py - (py - ty) * ratio;
scale = next;
apply();
}
function bounds(nodes) {
if (!nodes.length) return null;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach(function (n) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width);
maxY = Math.max(maxY, n.y + n.height);
});
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
return {
GRID: GRID,
init: function (surfaceEl, worldEl) {
surface = surfaceEl;
world = worldEl;
apply();
},
get: function () { return { x: tx, y: ty, scale: scale }; },
scale: function () { return scale; },
toWorld: function (clientX, clientY) {
var r = surface.getBoundingClientRect();
return {
x: (clientX - r.left - tx) / scale,
y: (clientY - r.top - ty) / scale
};
},
toScreen: function (x, y) {
var r = surface.getBoundingClientRect();
return { x: x * scale + tx + r.left, y: y * scale + ty + r.top };
},
pan: function (dx, dy) {
tx += dx;
ty += dy;
apply();
},
zoomAt: zoomAt,
// zoomBy scales about the viewport centre, for the toolbar buttons and
// the +/- shortcuts where there is no cursor to anchor to.
zoomBy: function (factor) {
var r = surface.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, factor);
},
resetZoom: function () {
var r = surface.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / scale);
},
// fit frames the whole document with a margin, never zooming past 1:1 —
// a canvas with three small nodes should not open comically magnified.
fit: function (nodes) {
var b = bounds(nodes);
var r = surface.getBoundingClientRect();
if (!b || !r.width || !r.height) {
tx = 0; ty = 0; scale = 1;
apply();
return;
}
var pad = 60;
var s = clampScale(Math.min(
(r.width - pad * 2) / Math.max(b.width, 1),
(r.height - pad * 2) / Math.max(b.height, 1),
1
));
scale = s;
tx = r.width / 2 - (b.x + b.width / 2) * s;
ty = r.height / 2 - (b.y + b.height / 2) * s;
apply();
},
// centreOfViewport is where a newly added node goes: wherever the user
// is currently looking.
centreOfViewport: function () {
var r = surface.getBoundingClientRect();
return {
x: (r.width / 2 - tx) / scale,
y: (r.height / 2 - ty) / scale
};
},
bounds: bounds,
onChange: function (fn) { listeners.push(fn); },
refresh: apply
};
})();