// canvas.js — the JSON Canvas editor (canvas/main.html, canvas.go).
//
// The document lives here as the parsed file (#canvas-data); every change
// mutates it in place, so fields this editor does not know survive the
// autosave. Node elements always come from the server: on load they are in
// the page, and a node whose content changed is re-rendered by POST ?render.
// Only geometry, color and selection are applied locally. Edges are drawn
// here, since they follow every drag.
//
// Mouse: drag the background to pan (Shift+drag selects), wheel zooms,
// double-click the background for a new note, double-click a node to edit it,
// drag a node's side handle onto another node to connect them. Touch: one
// finger pans or drags, two fingers pinch.
(function () {
var app = document.querySelector('.canvas-app');
if (!app) return;
var viewport = app.querySelector('.canvas-viewport');
var world = app.querySelector('.canvas-world');
var nodesEl = app.querySelector('.canvas-nodes');
var edgesSvg = app.querySelector('.canvas-edges');
var labelsEl = app.querySelector('.canvas-edge-labels');
var hintEl = app.querySelector('.canvas-hint');
var statusEl = app.querySelector('.canvas-status');
var zoomEl = app.querySelector('.canvas-zoom');
var nodeToolbar = app.querySelector('.canvas-node-toolbar');
var readOnly = app.hasAttribute('data-readonly');
var doc = JSON.parse(document.getElementById('canvas-data').textContent);
if (!Array.isArray(doc.nodes)) doc.nodes = [];
if (!Array.isArray(doc.edges)) doc.edges = [];
var version = app.dataset.version;
var baseURL = window.location.pathname;
var GRID = 10;
var MIN_W = 60, MIN_H = 40;
var SIDES = ['top', 'right', 'bottom', 'left'];
var NORMALS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
var byId = {}; // node id → node
var els = {}; // node id → element
var elKey = {}; // node id → content key its element shows
var htmlCache = {}; // content key → element HTML from the server
var selected = new Set(); // node ids
var selectedEdges = new Set();
var editing = null; // the text node being edited, see startEdit
var cam = { x: 0, y: 0, z: 1 };
// === Helpers ===
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
function newId() {
var a = new Uint8Array(8);
crypto.getRandomValues(a);
return Array.prototype.map.call(a, function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
}
function snap(v) { return Math.round(v / GRID) * GRID; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function reindex() {
byId = {};
doc.nodes.forEach(function (n) { byId[n.id] = n; });
}
// contentKey covers everything that changes a node's rendered element
// other than geometry and color.
function contentKey(n) {
return JSON.stringify([n.type, n.text, n.file, n.subpath, n.url, n.label, n.background, n.backgroundStyle]);
}
function fromHTML(html) {
var t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
function setColor(target, color, isEl) {
if (/^[1-6]$/.test(color || '')) {
target.setAttribute('data-color', color);
target.style.removeProperty(isEl ? '--node-color' : '--edge-color');
} else if (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color || '')) {
target.removeAttribute('data-color');
target.style.setProperty(isEl ? '--node-color' : '--edge-color', color);
} else {
target.removeAttribute('data-color');
target.style.removeProperty(isEl ? '--node-color' : '--edge-color');
}
}
function place(el, n) {
el.style.left = n.x + 'px';
el.style.top = n.y + 'px';
el.style.width = n.width + 'px';
el.style.height = n.height + 'px';
setColor(el, n.color, true);
el.classList.toggle('is-selected', selected.has(n.id));
}
// decorate adds the editing handles to a node element.
function decorate(el) {
if (readOnly) return el;
SIDES.forEach(function (side) {
var h = document.createElement('span');
h.className = 'canvas-handle';
h.dataset.side = side;
el.appendChild(h);
});
var r = document.createElement('span');
r.className = 'canvas-resize';
el.appendChild(r);
return el;
}
function modalOpen() {
return !!document.querySelector('.modal-backdrop');
}
// === Camera ===
var camKey = 'canvas-cam:' + baseURL;
function applyCam() {
world.style.transform = 'translate(' + cam.x + 'px,' + cam.y + 'px) scale(' + cam.z + ')';
world.style.setProperty('--z', cam.z);
var g = 20 * cam.z;
viewport.style.backgroundSize = g + 'px ' + g + 'px';
viewport.style.backgroundPosition = cam.x + 'px ' + cam.y + 'px';
viewport.classList.toggle('is-far', cam.z < 0.4);
if (zoomEl) zoomEl.textContent = Math.round(cam.z * 100) + '%';
try { sessionStorage.setItem(camKey, JSON.stringify(cam)); } catch (e) {}
}
function viewportPoint(clientX, clientY) {
var r = viewport.getBoundingClientRect();
return { x: clientX - r.left, y: clientY - r.top };
}
function toWorld(clientX, clientY) {
var p = viewportPoint(clientX, clientY);
return { x: (p.x - cam.x) / cam.z, y: (p.y - cam.y) / cam.z };
}
function zoomAt(px, py, z) {
z = clamp(z, 0.1, 4);
cam.x = px - (px - cam.x) * z / cam.z;
cam.y = py - (py - cam.y) * z / cam.z;
cam.z = z;
applyCam();
}
function zoomCenter(factor) {
zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, cam.z * factor);
}
function bounds(nodes) {
if (!nodes.length) return null;
var b = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity };
nodes.forEach(function (n) {
b.x1 = Math.min(b.x1, n.x);
b.y1 = Math.min(b.y1, n.y);
b.x2 = Math.max(b.x2, n.x + n.width);
b.y2 = Math.max(b.y2, n.y + n.height);
});
return b;
}
function fit() {
var w = viewport.clientWidth, h = viewport.clientHeight;
var b = bounds(doc.nodes);
if (!b) {
cam = { x: w / 2, y: h / 2, z: 1 };
} else {
var pad = 40;
var z = clamp(Math.min((w - 2 * pad) / (b.x2 - b.x1), (h - 2 * pad) / (b.y2 - b.y1), 1), 0.1, 1);
cam = {
x: (w - (b.x2 - b.x1) * z) / 2 - b.x1 * z,
y: (h - (b.y2 - b.y1) * z) / 2 - b.y1 * z,
z: z,
};
}
applyCam();
}
// The point new nodes go to: the middle of the view.
function viewCenter() {
return {
x: (viewport.clientWidth / 2 - cam.x) / cam.z,
y: (viewport.clientHeight / 2 - cam.y) / cam.z,
};
}
// === Rendering ===
var pending = {}; // content key → in-flight render
// renderNode fetches the element for n's current content and swaps it in
// once it arrives, unless the node changed again in the meantime.
function renderNode(n) {
var key = contentKey(n);
if (pending[key]) return pending[key];
var p = fetch(baseURL + '?render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(n),
}).then(function (res) {
if (!res.ok) throw new Error('render failed (' + res.status + ')');
return res.text();
}).then(function (html) {
htmlCache[key] = html;
var cur = byId[n.id];
if (cur && contentKey(cur) === key && elKey[n.id] !== key && !(editing && editing.id === n.id)) {
swapElement(cur, key);
if (cur.type === 'text') growToFit(cur);
renderEdges();
}
}).catch(function (err) {
setStatus('error', err.message);
}).then(function () {
delete pending[key];
});
pending[key] = p;
return p;
}
function swapElement(n, key) {
var el = decorate(fromHTML(htmlCache[key]));
var old = els[n.id];
if (old) old.replaceWith(el);
else nodesEl.appendChild(el);
els[n.id] = el;
elKey[n.id] = key;
place(el, n);
}
// growToFit makes a text node tall enough for its content. It never
// shrinks a node: the size is the user's.
function growToFit(n) {
var el = els[n.id];
var body = el && el.querySelector('.canvas-node-body');
if (!body) return;
var extra = body.scrollHeight - body.clientHeight;
if (extra > 1) {
n.height = snap(n.height + extra + GRID / 2);
place(el, n);
scheduleSave();
}
}
// syncDOM brings the node elements in line with doc.nodes: creates,
// removes, reorders (array order is z-order), re-renders changed content.
function syncDOM() {
reindex();
var keep = {};
doc.nodes.forEach(function (n, i) {
keep[n.id] = true;
var key = contentKey(n);
var el = els[n.id];
if (elKey[n.id] !== key && !(editing && editing.id === n.id)) {
if (htmlCache[key] !== undefined) {
swapElement(n, key);
el = els[n.id];
} else {
if (!el) {
// A placeholder until the server's element arrives.
el = decorate(fromHTML('
'));
els[n.id] = el;
elKey[n.id] = null;
}
renderNode(n);
}
}
place(el, n);
if (nodesEl.children[i] !== el) nodesEl.insertBefore(el, nodesEl.children[i] || null);
});
Object.keys(els).forEach(function (id) {
if (keep[id]) return;
els[id].remove();
delete els[id];
delete elKey[id];
selected.delete(id);
});
selectedEdges.forEach(function (id) {
if (!doc.edges.some(function (e) { return e.id === id; })) selectedEdges.delete(id);
});
if (hintEl) hintEl.hidden = doc.nodes.length > 0;
renderEdges();
}
// --- Edges ---
function center(n) { return { x: n.x + n.width / 2, y: n.y + n.height / 2 }; }
function anchor(n, side) {
switch (side) {
case 'top': return { x: n.x + n.width / 2, y: n.y };
case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height };
case 'left': return { x: n.x, y: n.y + n.height / 2 };
default: return { x: n.x + n.width, y: n.y + n.height / 2 };
}
}
// sideToward picks the side of n facing point p.
function sideToward(n, p) {
var c = center(n);
var dx = (p.x - c.x) / Math.max(n.width, 1), dy = (p.y - c.y) / Math.max(n.height, 1);
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
return dy > 0 ? 'bottom' : 'top';
}
// curve returns the bezier of an edge from p1 (leaving side s1) to p2
// (entering side s2; null for a free end like the pointer).
function curve(p1, s1, p2, s2) {
var d = Math.hypot(p2.x - p1.x, p2.y - p1.y);
var off = clamp(d / 2, 30, 150);
var n1 = NORMALS[s1], n2 = s2 ? NORMALS[s2] : [0, 0];
return {
p1: p1,
c1: { x: p1.x + n1[0] * off, y: p1.y + n1[1] * off },
c2: { x: p2.x + n2[0] * off, y: p2.y + n2[1] * off },
p2: p2,
};
}
function curvePath(c) {
return 'M' + c.p1.x + ' ' + c.p1.y + 'C' + c.c1.x + ' ' + c.c1.y + ' ' + c.c2.x + ' ' + c.c2.y + ' ' + c.p2.x + ' ' + c.p2.y;
}
// arrow draws an arrowhead with its tip at p, pointing from control
// point c toward p.
function arrow(p, c) {
var dx = p.x - c.x, dy = p.y - c.y;
var len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
var s = 12, w = 6;
var bx = p.x - dx * s, by = p.y - dy * s;
return 'M' + p.x + ' ' + p.y + 'L' + (bx - dy * w) + ' ' + (by + dx * w) + 'L' + (bx + dy * w) + ' ' + (by - dx * w) + 'Z';
}
function edgeGeometry(e) {
var a = byId[e.fromNode], b = byId[e.toNode];
if (!a || !b) return null;
var s1 = e.fromSide || sideToward(a, center(b));
var s2 = e.toSide || sideToward(b, center(a));
return curve(anchor(a, s1), s1, anchor(b, s2), s2);
}
var draft = null; // the connection being dragged: { from, side, to: {x,y} }
function renderEdges() {
var parts = [];
var labels = [];
var box = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity };
function grow(p) {
box.x1 = Math.min(box.x1, p.x); box.y1 = Math.min(box.y1, p.y);
box.x2 = Math.max(box.x2, p.x); box.y2 = Math.max(box.y2, p.y);
}
doc.edges.forEach(function (e) {
var c = edgeGeometry(e);
if (!c) return;
[c.p1, c.c1, c.c2, c.p2].forEach(grow);
var d = curvePath(c);
var preset = /^[1-6]$/.test(e.color || '') ? ' data-color="' + e.color + '"' : '';
var hex = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(e.color || '') ? '--edge-color:' + e.color + ';' : '';
var g = '' +
'';
if ((e.toEnd || 'arrow') === 'arrow') g += '';
if ((e.fromEnd || 'none') === 'arrow') g += '';
parts.push(g + '');
if (e.label) {
var mx = (c.p1.x + 3 * c.c1.x + 3 * c.c2.x + c.p2.x) / 8;
var my = (c.p1.y + 3 * c.c1.y + 3 * c.c2.y + c.p2.y) / 8;
labels.push('' + esc(e.label) + '
');
}
});
if (draft) {
var from = byId[draft.from];
var c = curve(anchor(from, draft.side), draft.side, draft.to, null);
[c.p1, c.c1, c.c2, c.p2].forEach(grow);
parts.push('');
}
if (box.x1 === Infinity) {
edgesSvg.style.width = edgesSvg.style.height = '0';
edgesSvg.innerHTML = '';
} else {
var m = 40;
var x = box.x1 - m, y = box.y1 - m;
edgesSvg.style.left = x + 'px';
edgesSvg.style.top = y + 'px';
edgesSvg.style.width = (box.x2 - box.x1 + 2 * m) + 'px';
edgesSvg.style.height = (box.y2 - box.y1 + 2 * m) + 'px';
edgesSvg.innerHTML = '' + parts.join('') + '';
}
labelsEl.innerHTML = labels.join('');
}
// === Selection ===
function select(ids, edgeIds) {
selected = new Set(ids || []);
selectedEdges = new Set(edgeIds || []);
Object.keys(els).forEach(function (id) {
els[id].classList.toggle('is-selected', selected.has(id));
});
renderEdges();
}
function toggleSelected(id) {
var s = new Set(selected);
if (s.has(id)) s.delete(id); else s.add(id);
select(Array.from(s), Array.from(selectedEdges));
}
// === Undo, mutations ===
var undoStack = [], redoStack = [];
function snapshot() {
return JSON.stringify({ nodes: doc.nodes, edges: doc.edges });
}
function pushUndo(before) {
if (before === snapshot()) return;
undoStack.push(before);
if (undoStack.length > 200) undoStack.shift();
redoStack = [];
}
// mutate runs fn as one undoable change.
function mutate(fn) {
if (readOnly) return;
var before = snapshot();
fn();
pushUndo(before);
syncDOM();
scheduleSave();
}
function restore(s) {
var d = JSON.parse(s);
doc.nodes = d.nodes;
doc.edges = d.edges;
syncDOM();
select(Array.from(selected).filter(function (id) { return byId[id]; }));
scheduleSave();
}
function undo() {
commitEdit();
if (!undoStack.length) return;
redoStack.push(snapshot());
restore(undoStack.pop());
}
function redo() {
commitEdit();
if (!redoStack.length) return;
undoStack.push(snapshot());
restore(redoStack.pop());
}
// === Saving ===
// Every change saves itself shortly after. A save carries the version it
// is based on; the server refuses it (409) when the file changed
// elsewhere, and from then on nothing is saved until a reload.
var saveTimer = null, saving = false, dirty = false, failed = false, conflict = false;
function setStatus(state, msg) {
if (!statusEl) return;
var text = {
saved: 'Saved',
unsaved: 'Unsaved',
saving: 'Saving…',
error: 'Not saved: ' + (msg || 'error'),
conflict: 'Changed elsewhere — reload to continue',
}[state];
statusEl.textContent = text;
statusEl.title = msg || '';
statusEl.classList.toggle('is-error', state === 'error' || state === 'conflict');
}
function scheduleSave() {
if (readOnly || conflict) return;
dirty = true;
if (!saving) setStatus('unsaved');
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 800);
}
function save() {
clearTimeout(saveTimer);
saveTimer = null;
if (saving || !dirty || conflict) return;
saving = true;
dirty = false;
setStatus('saving');
var retry = false;
fetch(baseURL + '?save', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Canvas-Version': version },
body: JSON.stringify(doc),
}).then(function (res) {
if (res.ok) {
version = res.headers.get('X-Canvas-Version') || version;
failed = false;
return;
}
return res.text().then(function (msg) {
failed = true;
dirty = true;
if (res.status === 409) {
conflict = true;
setStatus('conflict', msg);
} else {
// Server errors may pass; a refused document would only
// be refused again, so that waits for the next change.
retry = res.status >= 500;
setStatus('error', msg.trim() || String(res.status));
}
});
}).catch(function () {
failed = true;
dirty = true;
retry = true;
setStatus('error', 'network error');
}).then(function () {
saving = false;
if (conflict) return;
if (retry) {
saveTimer = setTimeout(save, 5000);
} else if (dirty && !failed) {
save();
} else if (!dirty) {
setStatus('saved');
}
});
}
// Leaving the page: commit an open editor and send what is unsaved with
// keepalive, which outlives the page (up to 64 KB). Only a save that cannot
// go out that way asks the user to stay.
window.addEventListener('beforeunload', function (e) {
commitEdit();
if (!dirty || conflict) return;
var body = JSON.stringify(doc);
if (!saving && body.length < 60000) {
clearTimeout(saveTimer);
dirty = false;
saving = true;
fetch(baseURL + '?save', {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-Canvas-Version': version },
body: body,
});
return;
}
e.preventDefault();
e.returnValue = '';
});
// A tab going to the background may never come back (mobile).
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden' && dirty && !saving) save();
});
// === Editing nodes ===
// startEdit opens the markdown editor (editor/core.js) inside a text node.
function startEdit(id, isNew) {
var n = byId[id];
if (readOnly || !n || n.type !== 'text' || !window.MarkdownEditor) return;
if (editing) {
if (editing.id === id) return;
commitEdit();
}
select([id]);
var el = els[id];
var body = el.querySelector('.canvas-node-body');
var mount = document.createElement('div');
mount.className = 'canvas-node-editor';
body.hidden = true;
el.appendChild(mount);
el.classList.add('is-editing');
nodeToolbar.hidden = false;
var commitKey = function () {
if (document.querySelector('.cm-tooltip-autocomplete')) return false;
commitEdit();
return true;
};
editing = {
id: id,
el: el,
body: body,
mount: mount,
isNew: !!isNew,
before: snapshot(),
editor: MarkdownEditor.create(mount, {
doc: n.text || '',
keymap: [{ key: 'Escape', run: commitKey }, { key: 'Mod-Enter', run: commitKey }],
}),
};
}
function commitEdit() {
if (!editing) return;
var e = editing;
editing = null;
var text = e.editor.getValue();
e.editor.destroy();
e.mount.remove();
e.body.hidden = false;
e.el.classList.remove('is-editing');
nodeToolbar.hidden = true;
var n = byId[e.id];
if (!n) return;
if (e.isNew && !text.trim()) {
// An empty new note was a misclick: drop it without a trace.
doc.nodes = doc.nodes.filter(function (x) { return x.id !== n.id; });
doc.edges = doc.edges.filter(function (x) { return x.fromNode !== n.id && x.toNode !== n.id; });
syncDOM();
if (e.before !== snapshot()) pushUndo(e.before);
scheduleSave();
} else if (text !== (n.text || '') || e.isNew) {
n.text = text;
pushUndo(e.before);
syncDOM();
scheduleSave();
}
viewport.focus({ preventScroll: true });
}
function promptText(title, value, placeholder, onDone) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = placeholder || '';
input.value = value || '';
var handle = openModal({
title: title,
body: input,
confirm: {
label: 'OK',
onConfirm: function () {
handle.close();
onDone(input.value.trim());
}
}
});
}
// editNode is the double-click / Enter action for every node type.
function editNode(id) {
var n = byId[id];
if (!n || readOnly) return;
switch (n.type) {
case 'text':
startEdit(id);
break;
case 'group':
promptText('Group label', n.label, 'Label (optional)', function (v) {
mutate(function () { if (v) n.label = v; else delete n.label; });
});
break;
case 'link':
promptText('Link', n.url, 'https://…', function (v) {
if (v) mutate(function () { n.url = v; });
});
break;
case 'file':
pickFile(function (rel) {
mutate(function () { n.file = rel; delete n.subpath; });
});
break;
}
}
function editEdgeLabel(id) {
var e = doc.edges.find(function (x) { return x.id === id; });
if (!e || readOnly) return;
promptText('Connection label', e.label, 'Label (optional)', function (v) {
mutate(function () { if (v) e.label = v; else delete e.label; });
});
}
function pageFolder() {
return decodeURIComponent(baseURL.slice(0, baseURL.lastIndexOf('/'))) || '/';
}
function pickFile(onPick) {
openTreePicker({
title: 'Pick a file or page',
mode: 'any',
initialPath: pageFolder(),
allowRoot: false,
confirmLabel: 'ADD',
onSelect: function (absPath) { onPick(absPath.replace(/^\/+/, '')); }
});
}
// === Commands ===
function addNode(n, at) {
n.id = newId();
var c = at || viewCenter();
n.x = snap(c.x - n.width / 2);
n.y = snap(c.y - n.height / 2);
mutate(function () {
if (n.type === 'group') doc.nodes.unshift(n); else doc.nodes.push(n);
});
select([n.id]);
return n;
}
// addText adds a note and opens its editor. Creating and writing it is
// one undo step, and a note left empty disappears (commitEdit).
function addText(at) {
commitEdit();
var before = snapshot();
var n = addNode({ type: 'text', text: '', width: 260, height: 80 }, at);
undoStack.pop();
startEdit(n.id, true);
editing.before = before;
return n;
}
function addFile() {
pickFile(function (rel) {
var image = /\.(png|jpe?g|gif|webp|svg)$/i.test(rel);
var card = !image && !/\.md$/i.test(rel) && /\.[^\/]+$/.test(rel);
addNode({ type: 'file', file: rel, width: card ? 300 : 400, height: card ? 60 : image ? 300 : 400 });
});
}
function addLink() {
promptText('Add link', '', 'https://…', function (v) {
if (v) addNode({ type: 'link', url: v, width: 320, height: 70 });
});
}
// addGroup puts a group around the selection, or an empty one in view.
function addGroup() {
var nodes = Array.from(selected).map(function (id) { return byId[id]; }).filter(Boolean);
var b = bounds(nodes);
var n = { type: 'group', width: 400, height: 300 };
var at;
if (b) {
var pad = 30;
n.width = b.x2 - b.x1 + 2 * pad;
n.height = b.y2 - b.y1 + 2 * pad;
at = { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2 };
}
n = addNode(n, at);
editNode(n.id);
}
function deleteSelection() {
if (!selected.size && !selectedEdges.size) return;
mutate(function () {
doc.nodes = doc.nodes.filter(function (n) { return !selected.has(n.id); });
doc.edges = doc.edges.filter(function (e) {
return !selectedEdges.has(e.id) && !selected.has(e.fromNode) && !selected.has(e.toNode);
});
});
select([]);
}
function duplicateSelection() {
if (!selected.size) return;
var map = {};
var copies = [];
mutate(function () {
doc.nodes.filter(function (n) { return selected.has(n.id); }).forEach(function (n) {
var c = JSON.parse(JSON.stringify(n));
c.id = newId();
c.x += 3 * GRID;
c.y += 3 * GRID;
map[n.id] = c.id;
copies.push(c);
});
doc.nodes = doc.nodes.concat(copies);
doc.edges.filter(function (e) { return map[e.fromNode] && map[e.toNode]; }).forEach(function (e) {
var c = JSON.parse(JSON.stringify(e));
c.id = newId();
c.fromNode = map[e.fromNode];
c.toNode = map[e.toNode];
doc.edges.push(c);
});
});
select(copies.map(function (c) { return c.id; }));
}
function setSelectionColor(color) {
if (!selected.size && !selectedEdges.size) return;
function paint(x) {
if (color) x.color = color; else delete x.color;
}
mutate(function () {
doc.nodes.forEach(function (n) { if (selected.has(n.id)) paint(n); });
doc.edges.forEach(function (e) { if (selectedEdges.has(e.id)) paint(e); });
});
}
function setEnds(value) {
if (!selectedEdges.size) return;
mutate(function () {
doc.edges.forEach(function (e) {
if (!selectedEdges.has(e.id)) return;
if (value === 'reverse') {
var t = [e.fromNode, e.fromSide, e.fromEnd];
e.fromNode = e.toNode; e.fromSide = e.toSide; e.fromEnd = e.toEnd;
e.toNode = t[0]; e.toSide = t[1]; e.toEnd = t[2];
['fromSide', 'fromEnd', 'toSide', 'toEnd'].forEach(function (k) { if (e[k] === undefined) delete e[k]; });
return;
}
var v = value.split(' ');
if (v[0] === 'none') delete e.fromEnd; else e.fromEnd = v[0];
if (v[1] === 'arrow') delete e.toEnd; else e.toEnd = v[1];
});
});
}
function nudge(dx, dy) {
if (!selected.size) return;
mutate(function () {
movedSet(Array.from(selected)).forEach(function (id) {
byId[id].x += dx;
byId[id].y += dy;
});
});
}
// movedSet is what moves with the given nodes: groups take the nodes
// inside them along.
function movedSet(ids) {
var out = new Set(ids);
ids.forEach(function (id) {
var g = byId[id];
if (!g || g.type !== 'group') return;
doc.nodes.forEach(function (n) {
if (n.id !== g.id && n.x >= g.x && n.y >= g.y &&
n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height) out.add(n.id);
});
});
return Array.from(out);
}
// === Pointer input ===
var gesture = null;
var pointers = new Map();
var spaceDown = false;
var suppressClick = false;
function startPinch() {
var pts = Array.from(pointers.values());
var a = viewportPoint(pts[0].x, pts[0].y), b = viewportPoint(pts[1].x, pts[1].y);
var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
gesture = {
type: 'pinch',
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
world: { x: (mid.x - cam.x) / cam.z, y: (mid.y - cam.y) / cam.z },
z: cam.z,
};
}
viewport.addEventListener('pointerdown', function (e) {
if (editing && editing.el.contains(e.target)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
if (gesture && gesture.before) restore(gesture.before);
startPinch();
return;
}
if (pointers.size > 2) return;
commitEdit();
viewport.focus({ preventScroll: true });
var base = { startX: e.clientX, startY: e.clientY, moved: false };
var nodeEl = e.target.closest('.canvas-node');
var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label');
var additive = e.shiftKey || e.ctrlKey || e.metaKey;
if (e.button === 1 || (e.button === 0 && spaceDown)) {
e.preventDefault();
gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) });
return;
}
if (e.button !== 0) return;
if (nodeEl && !readOnly && e.target.closest('.canvas-handle')) {
e.preventDefault();
gesture = Object.assign(base, { type: 'connect', from: nodeEl.dataset.id, side: e.target.dataset.side });
return;
}
if (nodeEl && !readOnly && e.target.closest('.canvas-resize')) {
e.preventDefault();
var rn = byId[nodeEl.dataset.id];
gesture = Object.assign(base, { type: 'resize', id: rn.id, w: rn.width, h: rn.height, before: snapshot() });
return;
}
if (nodeEl) {
var id = nodeEl.dataset.id;
var wasSelected = selected.has(id);
if (additive) toggleSelected(id);
else if (!wasSelected) select([id]);
if (readOnly || (additive && wasSelected)) return;
var ids = movedSet(Array.from(selected));
var origins = {};
ids.forEach(function (i) { origins[i] = { x: byId[i].x, y: byId[i].y }; });
gesture = Object.assign(base, { type: 'move', id: id, ids: ids, origins: origins, additive: additive, before: snapshot() });
return;
}
if (edgeEl) {
var eid = edgeEl.dataset.id;
if (additive) {
var s = new Set(selectedEdges);
if (s.has(eid)) s.delete(eid); else s.add(eid);
select(Array.from(selected), Array.from(s));
} else {
select([], [eid]);
}
return;
}
// The background.
if (e.shiftKey) {
var box = document.createElement('div');
box.className = 'canvas-marquee';
viewport.appendChild(box);
gesture = Object.assign(base, { type: 'marquee', box: box, keep: Array.from(selected) });
} else {
if (!additive) select([]);
gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) });
}
});
window.addEventListener('pointermove', function (e) {
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (!gesture) return;
if (gesture.type === 'pinch') {
if (pointers.size < 2) return;
var pts = Array.from(pointers.values());
var a = viewportPoint(pts[0].x, pts[0].y), b = viewportPoint(pts[1].x, pts[1].y);
var z = clamp(gesture.z * Math.hypot(a.x - b.x, a.y - b.y) / gesture.dist, 0.1, 4);
cam.z = z;
cam.x = (a.x + b.x) / 2 - gesture.world.x * z;
cam.y = (a.y + b.y) / 2 - gesture.world.y * z;
applyCam();
return;
}
var dxs = e.clientX - gesture.startX, dys = e.clientY - gesture.startY;
if (!gesture.moved) {
if (Math.hypot(dxs, dys) < 4) return;
gesture.moved = true;
if (gesture.type === 'pan') viewport.classList.add('is-panning');
}
var dx = dxs / cam.z, dy = dys / cam.z;
switch (gesture.type) {
case 'pan':
cam.x = gesture.cam.x + dxs;
cam.y = gesture.cam.y + dys;
applyCam();
break;
case 'move':
gesture.ids.forEach(function (id) {
var n = byId[id], o = gesture.origins[id];
if (!n) return;
n.x = e.altKey ? Math.round(o.x + dx) : snap(o.x + dx);
n.y = e.altKey ? Math.round(o.y + dy) : snap(o.y + dy);
place(els[id], n);
});
renderEdges();
break;
case 'resize':
var n = byId[gesture.id];
n.width = Math.max(MIN_W, e.altKey ? Math.round(gesture.w + dx) : snap(gesture.w + dx));
n.height = Math.max(MIN_H, e.altKey ? Math.round(gesture.h + dy) : snap(gesture.h + dy));
place(els[n.id], n);
renderEdges();
break;
case 'connect':
draft = { from: gesture.from, side: gesture.side, to: toWorld(e.clientX, e.clientY) };
renderEdges();
break;
case 'marquee':
var p0 = viewportPoint(gesture.startX, gesture.startY), p1 = viewportPoint(e.clientX, e.clientY);
var r = { x1: Math.min(p0.x, p1.x), y1: Math.min(p0.y, p1.y), x2: Math.max(p0.x, p1.x), y2: Math.max(p0.y, p1.y) };
Object.assign(gesture.box.style, { left: r.x1 + 'px', top: r.y1 + 'px', width: (r.x2 - r.x1) + 'px', height: (r.y2 - r.y1) + 'px' });
var w1 = { x: (r.x1 - cam.x) / cam.z, y: (r.y1 - cam.y) / cam.z };
var w2 = { x: (r.x2 - cam.x) / cam.z, y: (r.y2 - cam.y) / cam.z };
var hit = doc.nodes.filter(function (n) {
return n.x < w2.x && n.x + n.width > w1.x && n.y < w2.y && n.y + n.height > w1.y;
}).map(function (n) { return n.id; });
select(gesture.keep.concat(hit));
break;
}
});
function endPointer(e) {
pointers.delete(e.pointerId);
var g = gesture;
if (!g) return;
if (g.type === 'pinch') {
if (pointers.size < 2) gesture = null;
return;
}
gesture = null;
viewport.classList.remove('is-panning');
if (g.moved) suppressClick = true;
switch (g.type) {
case 'move':
case 'resize':
if (g.moved) {
pushUndo(g.before);
scheduleSave();
} else if (g.type === 'move' && !g.additive && e.type === 'pointerup') {
select([g.id]);
}
break;
case 'connect':
draft = null;
if (!g.moved || e.type !== 'pointerup') {
renderEdges();
break;
}
var target = document.elementFromPoint(e.clientX, e.clientY);
var toEl = target && target.closest('.canvas-node');
var p = toWorld(e.clientX, e.clientY);
var edge = { id: newId(), fromNode: g.from, fromSide: g.side };
if (toEl && toEl.dataset.id !== g.from && byId[toEl.dataset.id]) {
var to = byId[toEl.dataset.id];
edge.toNode = to.id;
edge.toSide = target.classList.contains('canvas-handle') ? target.dataset.side : sideToward(to, p);
mutate(function () { doc.edges.push(edge); });
select([], [edge.id]);
} else if (!toEl) {
// Dropped on empty space: a new note there, connected.
var before = snapshot();
var n = { id: newId(), type: 'text', text: '', width: 260, height: 80 };
// Its side facing back sits at the drop point.
var side = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[g.side];
var dir = NORMALS[side];
n.x = snap(p.x - n.width / 2 - dir[0] * n.width / 2);
n.y = snap(p.y - n.height / 2 - dir[1] * n.height / 2);
edge.toNode = n.id;
edge.toSide = side;
doc.nodes.push(n);
doc.edges.push(edge);
syncDOM();
startEdit(n.id, true);
editing.before = before;
scheduleSave();
} else {
renderEdges();
}
break;
case 'marquee':
g.box.remove();
break;
}
}
window.addEventListener('pointerup', endPointer);
window.addEventListener('pointercancel', endPointer);
// A drag that started on a link or checkbox must not also click it.
window.addEventListener('click', function (e) {
if (!suppressClick) return;
suppressClick = false;
if (viewport.contains(e.target)) {
e.preventDefault();
e.stopPropagation();
}
}, true);
viewport.addEventListener('pointerdown', function () { suppressClick = false; }, true);
// Native drag of images and links would swallow the pointer moves.
viewport.addEventListener('dragstart', function (e) { e.preventDefault(); });
viewport.addEventListener('wheel', function (e) {
if (editing && editing.el.contains(e.target)) return;
var body = e.target.closest('.canvas-node-body');
if (body && !e.ctrlKey && body.scrollHeight > body.clientHeight + 1) return;
e.preventDefault();
var dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
if (e.shiftKey) {
cam.x -= dy;
applyCam();
return;
}
var p = viewportPoint(e.clientX, e.clientY);
zoomAt(p.x, p.y, cam.z * Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0015)));
}, { passive: false });
viewport.addEventListener('dblclick', function (e) {
if (readOnly || (editing && editing.el.contains(e.target))) return;
var nodeEl = e.target.closest('.canvas-node');
var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label');
if (nodeEl) {
if (e.target.closest('a, input, video, audio')) return;
e.preventDefault();
editNode(nodeEl.dataset.id);
} else if (edgeEl) {
editEdgeLabel(edgeEl.dataset.id);
} else {
addText(toWorld(e.clientX, e.clientY));
}
});
// Task checkboxes in notes flip their line in the note's text.
viewport.addEventListener('change', function (e) {
var box = e.target;
if (!box.matches || !box.matches('input.task-checkbox[data-task]')) return;
var nodeEl = box.closest('.canvas-node');
var n = nodeEl && byId[nodeEl.dataset.id];
var text = n && flipTask(n.text || '', +box.dataset.task, box.checked);
if (text === null || text === undefined || readOnly) {
box.checked = !box.checked;
return;
}
mutate(function () { n.text = text; });
});
// flipTask mirrors tasks.go flipTaskLine: the Nth task line outside
// fenced code gets its box set.
function flipTask(text, idx, checked) {
var lines = text.split('\n');
var inFence = false, count = 0;
for (var i = 0; i < lines.length; i++) {
var t = lines[i].replace(/^[ \t]+/, '');
if (t.indexOf('```') === 0 || t.indexOf('~~~') === 0) {
inFence = !inFence;
continue;
}
if (inFence) continue;
var m = /^(\s*[-*+]\s+)\[([ xX])\]/.exec(lines[i]);
if (!m) continue;
if (count === idx) {
lines[i] = m[1] + (checked ? '[x]' : '[ ]') + lines[i].slice(m[0].length);
return lines.join('\n');
}
count++;
}
return null;
}
// === Keyboard ===
function typingInField(e) {
var t = e.target;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
}
// Capture phase, so the page shortcuts (global-shortcuts.js) don't see
// the ones that mean something else here: E edits the selected node, M
// (move page) does not apply to a canvas.
window.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey || modalOpen()) return;
if (e.key === 'E') {
e.preventDefault();
e.stopPropagation();
if (!editing && selected.size === 1) editNode(Array.from(selected)[0]);
} else if (e.key === 'M') {
e.stopPropagation();
}
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === ' ' && !typingInField(e)) spaceDown = true;
if (editing || modalOpen() || typingInField(e) || readOnly) return;
var mod = e.ctrlKey || e.metaKey;
var step = e.shiftKey ? 1 : GRID;
switch (true) {
case e.key === 'Delete' || e.key === 'Backspace':
e.preventDefault();
deleteSelection();
break;
case mod && !e.shiftKey && e.key.toLowerCase() === 'z':
e.preventDefault();
undo();
break;
case mod && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z')):
e.preventDefault();
redo();
break;
case mod && e.key.toLowerCase() === 'd':
e.preventDefault();
duplicateSelection();
break;
case mod && e.key.toLowerCase() === 'a':
e.preventDefault();
select(doc.nodes.map(function (n) { return n.id; }));
break;
case e.key === 'Enter' && selected.size === 1:
e.preventDefault();
editNode(Array.from(selected)[0]);
break;
case e.key === 'Escape':
select([]);
break;
case e.key === 'ArrowLeft': e.preventDefault(); nudge(-step, 0); break;
case e.key === 'ArrowRight': e.preventDefault(); nudge(step, 0); break;
case e.key === 'ArrowUp': e.preventDefault(); nudge(0, -step); break;
case e.key === 'ArrowDown': e.preventDefault(); nudge(0, step); break;
}
});
document.addEventListener('keyup', function (e) {
if (e.key === ' ') spaceDown = false;
});
// === Toolbar ===
var commands = {
text: function () { addText(); },
file: addFile,
link: addLink,
group: addGroup,
color: function (btn) { setSelectionColor(btn.dataset.value); },
ends: function (btn) { setEnds(btn.dataset.value); },
undo: undo,
redo: redo,
'delete': deleteSelection,
zoomin: function () { zoomCenter(1.25); },
zoomout: function () { zoomCenter(0.8); },
zoomreset: function () { zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); },
fit: fit,
};
app.querySelectorAll('[data-canvas]').forEach(function (btn) {
btn.addEventListener('click', function () {
commitEdit();
commands[btn.dataset.canvas](btn);
});
});
app.querySelectorAll('.canvas-bar .dropdown-toggle').forEach(wireDropdown);
var colorInput = app.querySelector('.canvas-color-input');
if (colorInput) colorInput.addEventListener('change', function () { setSelectionColor(colorInput.value); });
if (window.MarkdownEditor) {
MarkdownEditor.bind(nodeToolbar, function () { return editing && editing.editor.actions; });
}
// === Start ===
reindex();
nodesEl.querySelectorAll('.canvas-node').forEach(function (el) {
var n = byId[el.dataset.id];
if (!n) return;
els[n.id] = el;
elKey[n.id] = contentKey(n);
htmlCache[elKey[n.id]] = el.outerHTML;
decorate(el);
});
renderEdges();
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(camKey)); } catch (e) {}
if (saved && isFinite(saved.x) && isFinite(saved.y) && saved.z > 0) {
cam = saved;
applyCam();
} else {
fit();
}
if (!readOnly) setStatus('saved');
viewport.focus({ preventScroll: true });
})();