JSON canvas initial
This commit is contained in:
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
};
|
||||
})();
|
||||
@@ -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}}
|
||||
@@ -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.
@@ -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); }
|
||||
};
|
||||
})();
|
||||
@@ -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); }
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user