Files
2026-10-04 15:58:27 +02:00

1917 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
//
// The handling follows jsoncanvas-desktop. There is no toolbar: everything
// is in the context menus (right-click, or a long press on touch) and the
// edit windows, which are non-modal and write through as you type.
//
// Mouse: drag the background to select, middle- or right-drag (or Space+drag)
// to pan, wheel zooms, double-click the background for a new note,
// double-click a node or edge to edit it, drag a node's side handle onto
// another node to connect them. Touch: one finger pans or drags, two fingers
// pinch, a long press opens the menu.
(function () {
var app = document.querySelector('.canvas-app');
if (!app) return;
var stage = app.querySelector('.canvas-stage');
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 toolbarHome = app.querySelector('.canvas-editor-toolbar');
var editorToolbar = toolbarHome && toolbarHome.firstElementChild;
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 COLORS = ['Red', 'Orange', 'Yellow', 'Green', 'Cyan', 'Purple'];
var LONG_PRESS = 500;
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 cam = { x: 0, y: 0, z: 1 };
// === Helpers ===
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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; });
}
function edgeById(id) {
return doc.edges.find(function (e) { return e.id === id; });
}
function selectedNodes() {
return doc.nodes.filter(function (n) { return selected.has(n.id); });
}
// 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 el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
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;
}
// title is how a node is named in the edge window.
function title(n) {
if (!n) return 'missing node';
var t;
switch (n.type) {
case 'text': t = (n.text || '').trim().split('\n')[0].replace(/^#+\s*/, ''); break;
case 'file': t = (n.file || '').split('/').pop(); break;
case 'link': t = n.label || n.url; break;
case 'group': t = n.label; break;
}
t = t || n.type;
return t.length > 40 ? t.slice(0, 39) + '…' : t;
}
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;
}
// fit shows the given nodes (all of them by default), never above 100%.
function fit(nodes) {
var w = viewport.clientWidth, h = viewport.clientHeight;
var b = bounds(nodes || 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();
}
function zoomToSelection() {
var nodes = selectedNodes();
fit(nodes.length ? nodes : null);
}
// 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) {
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;
}
// whenRendered runs fn once n's element shows its current content.
function whenRendered(n, fn) {
if (elKey[n.id] === contentKey(n)) fn();
else renderNode(n).then(fn);
}
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 (Fit to text does both).
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();
}
}
// fitHeight is the height n's card needs to show all of its text and no
// more, or null for a card whose content has no height of its own.
function fitHeight(n) {
var el = els[n.id];
var body = el && el.querySelector('.canvas-node-body.content');
if (!body || n.type === 'group') return null;
var h = el.style.height;
el.style.height = 'auto';
var natural = el.offsetHeight;
el.style.height = h;
return Math.max(MIN_H, Math.ceil(natural / GRID) * GRID);
}
// fitHeights sizes the given cards to their text; it reports whether any
// of them changed.
function fitHeights(ids) {
var changed = false;
ids.forEach(function (id) {
var n = byId[id], h = n && fitHeight(n);
if (h && h !== n.height) {
n.height = h;
changed = true;
}
});
return changed;
}
// 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) {
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('<div class="canvas-node canvas-node-' + esc(n.type) + ' is-pending" data-id="' + esc(n.id) + '"><div class="canvas-node-body"></div></div>'));
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 (!edgeById(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 = '<g class="canvas-edge' + (selectedEdges.has(e.id) ? ' is-selected' : '') + '" data-id="' + esc(e.id) + '"' + preset + ' style="' + hex + '">' +
'<path class="canvas-edge-hit" d="' + d + '"/><path class="canvas-edge-line" d="' + d + '"/>';
if ((e.toEnd || 'arrow') === 'arrow') g += '<path class="canvas-edge-arrow" d="' + arrow(c.p2, c.c2) + '"/>';
if ((e.fromEnd || 'none') === 'arrow') g += '<path class="canvas-edge-arrow" d="' + arrow(c.p1, c.c1) + '"/>';
parts.push(g + '</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('<div class="canvas-edge-label' + (selectedEdges.has(e.id) ? ' is-selected' : '') + '" data-id="' + esc(e.id) + '"' + preset +
' style="left:' + mx + 'px;top:' + my + 'px;' + hex + '">' + esc(e.label) + '</div>');
}
});
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('<g class="canvas-edge is-draft"><path class="canvas-edge-line" d="' + curvePath(c) + '"/></g>');
}
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 = '<g transform="translate(' + (-x) + ' ' + (-y) + ')">' + parts.join('') + '</g>';
}
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;
settle();
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() {
closeWindow();
if (!undoStack.length) return;
redoStack.push(snapshot());
restore(undoStack.pop());
}
function redo() {
closeWindow();
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');
}
// incomplete is true while the document would be refused: a link node
// just added has no URL until its window has one typed in.
function incomplete() {
return doc.nodes.some(function (n) { return n.type === 'link' && !n.url; });
}
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 || incomplete()) 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: 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) {
settle();
if (!dirty || conflict) return;
var body = JSON.stringify(doc);
if (!saving && !incomplete() && 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();
});
// === Edit windows ===
// One window at a time, for one node or edge. It is not modal: the canvas
// stays live behind it, and what is typed shows on the card as it is
// typed. Everything done in one window is one undo step (settle).
var win = null; // { id, edge, el, before, isNew, editor }
var renderTimer = null;
// settle closes the open window's undo step, so that whatever happens
// next on the canvas is a step of its own.
function settle() {
if (!win) return;
if (win.before !== snapshot()) pushUndo(win.before);
win.before = snapshot();
}
// live applies a change made in the window: the card follows shortly,
// the edges at once.
function live() {
renderEdges();
clearTimeout(renderTimer);
renderTimer = setTimeout(syncDOM, 250);
scheduleSave();
}
function inWindow(e) {
return !!(e.target && e.target.closest && e.target.closest('.canvas-window, .canvas-menu'));
}
// openWindow shows a window beside the card of opts.id (or in the middle
// of the view): opts.title, opts.body, opts.buttons [{ label, run }],
// opts.wide for the text editor, opts.before the undo state it started
// from, opts.isNew for a node that goes away if left empty.
function openWindow(opts) {
closeWindow();
var w = el('div', 'canvas-window panel panel-floating' + (opts.wide ? ' is-wide' : ''));
w.setAttribute('role', 'dialog');
var header = el('div', 'panel-header');
header.appendChild(el('span', 'truncate', opts.title));
var x = el('button', 'btn btn-tool', '×');
x.type = 'button';
x.title = 'Close (Esc)';
x.addEventListener('click', closeWindow);
header.appendChild(x);
var body = el('div', 'panel-body');
body.appendChild(opts.body);
var footer = el('div', 'panel-footer');
(opts.buttons || []).concat([{ label: 'CLOSE', run: closeWindow }]).forEach(function (b) {
var btn = el('button', 'btn', b.label);
btn.type = 'button';
if (b.title) btn.title = b.title;
btn.addEventListener('click', b.run);
footer.appendChild(btn);
});
w.appendChild(header);
w.appendChild(body);
w.appendChild(footer);
stage.appendChild(w);
w.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !e.defaultPrevented && !modalOpen()) {
e.preventDefault();
closeWindow();
}
});
dragByHeader(w, header);
win = {
id: opts.id,
edge: opts.edge,
el: w,
before: opts.before || snapshot(),
isNew: !!opts.isNew,
editor: null,
};
placeWindow(w, opts.edge ? null : opts.id);
if (!opts.wide) {
var first = body.querySelector('input, select');
if (first) {
first.focus();
if (first.select) first.select();
}
}
return win;
}
// placeWindow puts the window beside the card, on whichever side has
// more room, within the stage. Small screens dock it to the bottom (CSS).
function placeWindow(w, id) {
if (window.matchMedia('(max-width: 600px)').matches) return;
var s = stage.getBoundingClientRect();
var gap = 16;
var ww = w.offsetWidth, wh = w.offsetHeight;
var left = (s.width - ww) / 2, top = gap;
var card = id && els[id] && els[id].getBoundingClientRect();
if (card) {
var l = card.left - s.left, r = card.right - s.left;
left = s.width - r >= l ? r + gap : l - gap - ww;
top = card.top - s.top;
}
w.style.left = clamp(left, gap, Math.max(gap, s.width - ww - gap)) + 'px';
w.style.top = clamp(top, gap, Math.max(gap, s.height - wh - gap)) + 'px';
}
function dragByHeader(w, header) {
header.addEventListener('pointerdown', function (e) {
if (e.button !== 0 || e.target.closest('button') || window.matchMedia('(max-width: 600px)').matches) return;
e.preventDefault();
var s = stage.getBoundingClientRect();
var ox = w.offsetLeft - e.clientX, oy = w.offsetTop - e.clientY;
function move(ev) {
w.style.left = clamp(ev.clientX + ox, 0, s.width - w.offsetWidth) + 'px';
w.style.top = clamp(ev.clientY + oy, 0, s.height - header.offsetHeight) + 'px';
}
function up() {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
}
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});
}
function closeWindow() {
if (!win) return;
var w = win;
if (w.editor) {
w.editor.destroy();
if (editorToolbar) toolbarHome.appendChild(editorToolbar);
}
clearTimeout(renderTimer);
w.el.remove();
var n = byId[w.id];
// A new node left empty was a misclick: it goes without a trace.
if (w.isNew && n && (n.type === 'text' ? !(n.text || '').trim() : n.type === 'link' && !n.url)) {
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; });
}
settle();
win = null;
syncDOM();
scheduleSave();
viewport.focus({ preventScroll: true });
}
// --- Window parts ---
function field(label, control) {
var f = el('label', 'canvas-field');
f.appendChild(el('span', 'canvas-field-label', label));
f.appendChild(control);
return f;
}
function textInput(value, placeholder, onInput) {
var i = el('input', 'input');
i.type = 'text';
i.value = value || '';
i.placeholder = placeholder || '';
i.addEventListener('input', function () { onInput(i.value); });
return i;
}
function selectInput(options, value, onChange) {
var s = el('select', 'input');
options.forEach(function (o) {
var opt = el('option', '', o[1]);
opt.value = o[0];
s.appendChild(opt);
});
s.value = value || '';
s.addEventListener('change', function () { onChange(s.value); });
return s;
}
function smallButton(label, run) {
var b = el('button', 'btn btn-tool', label);
b.type = 'button';
b.addEventListener('click', run);
return b;
}
// set writes v to n[key], or drops the key for an empty value.
function set(n, key, v) {
if (v) n[key] = v; else delete n[key];
}
// --- The windows ---
// editNode is the double-click / Enter / F2 action for every node type.
function editNode(id, isNew, before) {
var n = byId[id];
if (!n || readOnly) return;
if (win && win.id === id && !win.edge) return;
switch (n.type) {
case 'text': editText(n, isNew, before); break;
case 'link': editLink(n, isNew, before); break;
case 'group': editGroup(n); break;
case 'file': editFile(n); break;
}
}
function editText(n, isNew, before) {
if (!window.MarkdownEditor) return;
var box = el('div', 'canvas-window-editor');
if (editorToolbar) box.appendChild(editorToolbar);
var mount = el('div', 'canvas-window-cm');
box.appendChild(mount);
var w = openWindow({
title: 'Edit text',
id: n.id,
body: box,
wide: true,
isNew: isNew,
before: before,
buttons: [{
label: 'CLOSE & FIT',
title: 'Close, and size the card to its text',
run: function () {
var cur = byId[w.id];
if (!cur) return closeWindow();
clearTimeout(renderTimer);
syncDOM();
whenRendered(cur, function () {
fitHeights([cur.id]);
closeWindow();
});
},
}],
});
var closeKey = function () {
if (document.querySelector('.cm-tooltip-autocomplete')) return false;
closeWindow();
return true;
};
w.editor = MarkdownEditor.create(mount, {
doc: n.text || '',
keymap: [{ key: 'Escape', run: closeKey }, { key: 'Mod-Enter', run: closeKey }],
onChange: function () {
var cur = byId[w.id];
if (!cur || win !== w) return;
cur.text = w.editor.getValue();
live();
},
});
}
function editLink(n, isNew, before) {
var box = el('div', 'canvas-fields');
box.appendChild(field('URL', textInput(n.url, 'https://', function (v) {
// A link needs its URL: clearing the field keeps the last one.
if (!v.trim()) return;
n.url = v.trim();
live();
})));
box.appendChild(field('Display text', textInput(n.label, 'Shown instead of the URL', function (v) {
set(n, 'label', v);
live();
})));
openWindow({ title: 'Edit link', id: n.id, body: box, isNew: isNew, before: before });
}
function editGroup(n) {
var box = el('div', 'canvas-fields');
box.appendChild(field('Label', textInput(n.label, 'Unnamed group', function (v) {
set(n, 'label', v);
live();
})));
var path = el('span', 'muted truncate', n.background || 'none');
var row = el('div', 'canvas-field-row');
row.appendChild(path);
row.appendChild(smallButton('CHOOSE…', function () {
pickFile('Background image', function (rel) {
n.background = rel;
path.textContent = rel;
live();
});
}));
row.appendChild(smallButton('CLEAR', function () {
delete n.background;
path.textContent = 'none';
live();
}));
box.appendChild(field('Background image', row));
box.appendChild(field('Fitting', selectInput(
[['', 'default'], ['cover', 'cover'], ['ratio', 'ratio'], ['repeat', 'repeat']],
n.backgroundStyle,
function (v) {
set(n, 'backgroundStyle', v);
live();
})));
openWindow({ title: 'Edit group', id: n.id, body: box });
}
function editFile(n) {
var box = el('div', 'canvas-fields');
var path = el('span', 'truncate', n.file);
var row = el('div', 'canvas-field-row');
row.appendChild(path);
row.appendChild(smallButton('CHANGE…', function () {
pickFile('Pick a file or page', function (rel) {
n.file = rel;
path.textContent = rel;
live();
});
}));
box.appendChild(field('File', row));
box.appendChild(field('Subpath', textInput(n.subpath, '#heading', function (v) {
set(n, 'subpath', v.trim());
live();
})));
openWindow({ title: 'Edit ' + (n.file || '').split('/').pop(), id: n.id, body: box });
}
function editEdge(id) {
var e = edgeById(id);
if (!e || readOnly) return;
select([], [id]);
var box = el('div', 'canvas-fields');
box.appendChild(el('span', 'muted', title(byId[e.fromNode]) + ' → ' + title(byId[e.toNode])));
box.appendChild(field('Label', textInput(e.label, 'Shown on the line', function (v) {
set(e, 'label', v);
live();
})));
var sides = [['', 'auto'], ['top', 'top'], ['right', 'right'], ['bottom', 'bottom'], ['left', 'left']];
function ends(def) { return [['', 'default (' + def + ')'], ['none', 'none'], ['arrow', 'arrow']]; }
function pair(a, b) {
var r = el('div', 'canvas-field-pair');
r.appendChild(a);
r.appendChild(b);
return r;
}
function choice(key, options) {
return selectInput(options, e[key], function (v) {
set(e, key, v);
live();
});
}
box.appendChild(pair(field('From side', choice('fromSide', sides)), field('Start', choice('fromEnd', ends('none')))));
box.appendChild(pair(field('To side', choice('toSide', sides)), field('End', choice('toEnd', ends('arrow')))));
openWindow({ title: 'Edit edge', id: id, edge: true, body: box });
}
function pageFolder() {
return decodeURIComponent(baseURL.slice(0, baseURL.lastIndexOf('/'))) || '/';
}
function pickFile(title, onPick) {
openTreePicker({
title: title,
mode: 'any',
initialPath: pageFolder(),
allowRoot: false,
confirmLabel: 'SELECT',
onSelect: function (absPath) { onPick(absPath.replace(/^\/+/, '')); }
});
}
// === Commands ===
// addNode puts n centered on at (the middle of the view by default).
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 window. Creating and writing it is
// one undo step, and a note left empty disappears (closeWindow).
function addText(at) {
closeWindow();
var before = snapshot();
var n = addNode({ type: 'text', text: '', width: 260, height: 80 }, at);
undoStack.pop();
editNode(n.id, true, before);
}
function addLink(at) {
closeWindow();
var before = snapshot();
var n = addNode({ type: 'link', url: '', width: 320, height: 70 }, at);
undoStack.pop();
editNode(n.id, true, before);
}
function addFile(at) {
pickFile('Pick a file or page', 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 }, at);
});
}
// addGroup puts a group around the selection, or an empty one at the
// given point or in view.
function addGroup(at, around) {
var b = bounds(around ? selectedNodes() : []);
var n = { type: 'group', label: 'Group', width: 400, height: 300 };
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;
if (win && (win.edge ? selectedEdges.has(win.id) : selected.has(win.id))) closeWindow();
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([]);
}
// insert adds copies of nodes (and the edges between them) with new ids,
// their box centered on at, and selects them.
function insert(nodes, edges, at) {
var b = bounds(nodes);
if (!b) return;
var dx = snap(at.x - (b.x1 + b.x2) / 2), dy = snap(at.y - (b.y1 + b.y2) / 2);
var map = {};
var copies = nodes.map(function (n) {
var c = JSON.parse(JSON.stringify(n));
c.id = newId();
c.x += dx;
c.y += dy;
map[n.id] = c.id;
return c;
});
mutate(function () {
doc.nodes = doc.nodes.concat(copies);
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; }));
}
// clip is the selection as a JSON Canvas document: groups take what is
// inside them along, and edges come when both ends do.
function clip() {
var ids = new Set(movedSet(Array.from(selected)));
return {
nodes: doc.nodes.filter(function (n) { return ids.has(n.id); }),
edges: doc.edges.filter(function (e) { return ids.has(e.fromNode) && ids.has(e.toNode); }),
};
}
function duplicateSelection() {
if (!selected.size) return;
var c = clip(), b = bounds(c.nodes);
insert(c.nodes, c.edges, { x: (b.x1 + b.x2) / 2 + 3 * GRID, y: (b.y1 + b.y2) / 2 + 3 * GRID });
}
// paste takes JSON Canvas (a copy from this editor or another), a URL
// (a link node) or any other text (a note).
function paste(text, at) {
var data = null;
try { data = JSON.parse(text); } catch (e) {}
if (data && Array.isArray(data.nodes) && data.nodes.length && data.nodes.every(function (n) {
return n && typeof n.id === 'string' && typeof n.type === 'string' && isFinite(n.x) && isFinite(n.y);
})) {
insert(data.nodes, Array.isArray(data.edges) ? data.edges : [], at);
} else if (/^https?:\/\/\S+$/.test(text.trim())) {
addNode({ type: 'link', url: text.trim(), width: 320, height: 70 }, at);
} else if (text.trim()) {
addNode({ type: 'text', text: text, width: 260, height: 80 }, at);
}
}
function paint(nodeIds, edgeIds, color) {
mutate(function () {
doc.nodes.forEach(function (n) { if (nodeIds.has(n.id)) set(n, 'color', color); });
doc.edges.forEach(function (e) { if (edgeIds.has(e.id)) set(e, 'color', color); });
});
}
function reverseEdge(e) {
mutate(function () {
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]; });
});
}
// raise moves the selection to the front (end of the array) or back.
function raise(front) {
mutate(function () {
var sel = doc.nodes.filter(function (n) { return selected.has(n.id); });
var rest = doc.nodes.filter(function (n) { return !selected.has(n.id); });
doc.nodes = front ? rest.concat(sel) : sel.concat(rest);
});
}
function align(how) {
var nodes = selectedNodes(), b = bounds(nodes);
if (nodes.length < 2) return;
mutate(function () {
nodes.forEach(function (n) {
switch (how) {
case 'left': n.x = b.x1; break;
case 'centre': n.x = Math.round((b.x1 + b.x2 - n.width) / 2); break;
case 'right': n.x = b.x2 - n.width; break;
case 'top': n.y = b.y1; break;
case 'middle': n.y = Math.round((b.y1 + b.y2 - n.height) / 2); break;
case 'bottom': n.y = b.y2 - n.height; break;
}
});
});
}
function fitSelection() {
mutate(function () { fitHeights(Array.from(selected)); });
}
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);
}
// openTarget is where a file or link node leads: the link the server put
// on its card.
function openTarget(id) {
var a = els[id] && els[id].querySelector('.canvas-node-label a, a.canvas-card, a.canvas-media');
return a ? a.href : null;
}
// === Context menus ===
var menu = null;
// openMenu shows items at a client point. An item is '-' (a separator),
// { label, run, danger, disabled }, { colors: current, run(color) } or
// { row: label, items: [{ label, run }] }.
function openMenu(clientX, clientY, items) {
closeMenu();
var m = el('div', 'canvas-menu panel panel-floating');
m.setAttribute('role', 'menu');
var last = '-';
items.forEach(function (item) {
if (!item || (item === '-' && last === '-')) return;
last = item;
if (item === '-') {
m.appendChild(el('div', 'canvas-menu-sep'));
} else if (item.colors !== undefined) {
m.appendChild(colorRow(item.colors, item.run));
} else if (item.row) {
var r = el('div', 'canvas-menu-row');
r.appendChild(el('span', 'muted', item.row));
item.items.forEach(function (sub) { r.appendChild(menuButton(sub, 'btn btn-tool')); });
m.appendChild(r);
} else {
m.appendChild(menuButton(item, 'btn btn-tool btn-block' + (item.danger ? ' danger' : '')));
}
});
if (last === '-' && m.lastChild) m.lastChild.remove();
if (!m.firstChild) return;
app.appendChild(m);
var x = clamp(clientX, 4, window.innerWidth - m.offsetWidth - 4);
var y = clientY + m.offsetHeight > window.innerHeight - 4 ? clientY - m.offsetHeight : clientY;
m.style.left = x + 'px';
m.style.top = clamp(y, 4, window.innerHeight - m.offsetHeight - 4) + 'px';
menu = m;
var first = m.querySelector('button:not(:disabled)');
if (first) first.focus({ preventScroll: true });
}
function menuButton(item, cls) {
var b = el('button', cls, item.label);
b.type = 'button';
b.setAttribute('role', 'menuitem');
b.disabled = !!item.disabled;
if (item.title) b.title = item.title;
b.addEventListener('click', function () {
closeMenu();
item.run();
});
return b;
}
// colorRow is the six presets, none, and a picker for any other color.
// current is the color to mark ('' for none), null for no mark.
function colorRow(current, run) {
var r = el('div', 'canvas-menu-row canvas-menu-colors');
r.appendChild(el('span', 'muted', 'Color'));
function swatch(value, name) {
var b = el('button', 'canvas-swatch' + (current === value ? ' is-current' : ''));
b.type = 'button';
b.title = name;
b.setAttribute('aria-label', name);
if (value) b.setAttribute('data-color', value);
b.addEventListener('click', function () {
closeMenu();
run(value);
});
return b;
}
r.appendChild(swatch('', 'None'));
COLORS.forEach(function (name, i) { r.appendChild(swatch(String(i + 1), name)); });
var custom = el('input', 'canvas-swatch canvas-swatch-custom' + (/^#/.test(current || '') ? ' is-current' : ''));
custom.type = 'color';
custom.title = 'Custom color';
custom.value = /^#[0-9a-fA-F]{6}$/.test(current || '') ? current : '#c48401';
custom.addEventListener('change', function () {
closeMenu();
run(custom.value);
});
r.appendChild(custom);
return r;
}
function closeMenu() {
if (!menu) return;
menu.remove();
menu = null;
}
document.addEventListener('pointerdown', function (e) {
if (menu && !menu.contains(e.target)) closeMenu();
}, true);
window.addEventListener('resize', closeMenu);
window.addEventListener('blur', closeMenu);
// contextMenu opens the menu for whatever is at target: a node, an edge
// or the bare canvas.
function contextMenu(clientX, clientY, target) {
if (modalOpen()) return;
var nodeEl = target.closest && target.closest('.canvas-node');
var edgeEl = target.closest && target.closest('.canvas-edge, .canvas-edge-label');
if (nodeEl && byId[nodeEl.dataset.id]) {
var id = nodeEl.dataset.id;
if (!selected.has(id)) select([id]);
openMenu(clientX, clientY, nodeMenu(byId[id]));
} else if (edgeEl && edgeById(edgeEl.dataset.id)) {
select([], [edgeEl.dataset.id]);
openMenu(clientX, clientY, edgeMenu(edgeById(edgeEl.dataset.id)));
} else {
openMenu(clientX, clientY, backgroundMenu(toWorld(clientX, clientY)));
}
}
// backgroundMenu: everything that can be added, landing at p.
function backgroundMenu(p) {
var view = [
{ label: 'Zoom to fit', run: function () { fit(); } },
{ label: 'Actual size', run: function () { zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); } },
];
if (readOnly) return view;
return [
{ label: 'Text', run: function () { addText(p); } },
{ label: 'File…', run: function () { addFile(p); } },
{ label: 'Link', run: function () { addLink(p); } },
'-',
{ label: 'Group', run: function () { addGroup(p); } },
'-',
{ label: 'Undo', run: undo, disabled: !undoStack.length },
{ label: 'Redo', run: redo, disabled: !redoStack.length },
{ label: 'Select all', run: function () { select(doc.nodes.map(function (n) { return n.id; })); }, disabled: !doc.nodes.length },
'-',
].concat(view);
}
// nodeMenu: what can be done to the selection, which n is part of.
function nodeMenu(n) {
var count = selected.size;
var single = count <= 1;
var target = (n.type === 'file' || n.type === 'link') && openTarget(n.id);
var open = target && {
label: n.type === 'link' ? 'Open in new tab' : 'Open',
run: function () {
if (n.type === 'link') window.open(target, '_blank', 'noopener');
else window.location.href = target;
},
};
if (readOnly) return single && open ? [open] : [];
var kind = [];
if (single) {
if (open) kind.push(open);
if (n.type === 'file') {
kind.push({ label: 'Change file…', run: function () {
pickFile('Pick a file or page', function (rel) {
mutate(function () { n.file = rel; delete n.subpath; });
});
} });
}
if (n.type === 'group') {
kind.push({ label: 'Background image…', run: function () {
pickFile('Background image', function (rel) { mutate(function () { n.background = rel; }); });
} });
kind.push({ label: 'Clear background', disabled: !n.background, run: function () {
mutate(function () { delete n.background; });
} });
}
}
var canFit = selectedNodes().some(function (x) { return fitHeight(x) !== null; });
return [
single && { label: 'Edit…', run: function () { editNode(n.id); } },
].concat(kind, [
{ label: 'Fit to text', run: fitSelection, disabled: !canFit, title: 'Size the card to show all of its text, and no more' },
'-',
{ colors: single ? n.color || '' : null, run: function (c) { paint(selected, new Set(), c); } },
'-',
{ label: 'Duplicate', run: duplicateSelection },
{ label: 'Put in a group', run: function () { addGroup(null, true); } },
{ label: 'Bring to front', run: function () { raise(true); } },
{ label: 'Send to back', run: function () { raise(false); } },
count >= 2 && { row: 'Align', items: [
{ label: 'LEFT', run: function () { align('left'); } },
{ label: 'CENTRE', run: function () { align('centre'); } },
{ label: 'RIGHT', run: function () { align('right'); } },
] },
count >= 2 && { row: '', items: [
{ label: 'TOP', run: function () { align('top'); } },
{ label: 'MIDDLE', run: function () { align('middle'); } },
{ label: 'BOTTOM', run: function () { align('bottom'); } },
] },
'-',
{ label: single ? 'Delete' : 'Delete ' + count + ' nodes', run: deleteSelection, danger: true },
]);
}
function edgeMenu(e) {
if (readOnly) return [];
return [
{ label: 'Edit…', run: function () { editEdge(e.id); }, title: 'Label, the sides it meets, and the shape of its ends' },
'-',
{ colors: e.color || '', run: function (c) { paint(new Set(), new Set([e.id]), c); } },
{ label: 'Reverse direction', run: function () { reverseEdge(e); } },
'-',
{ label: 'Delete edge', run: deleteSelection, danger: true },
];
}
// The browser's own menu never shows over the canvas or ours. Where it
// comes on mouseup (Windows), its event lands on our menu, which was
// just opened under the pointer, rather than on the viewport. Fields in
// the edit window keep it, for spelling and paste.
document.addEventListener('contextmenu', function (e) {
if (viewport.contains(e.target) || (menu && menu.contains(e.target))) e.preventDefault();
});
// === Pointer input ===
var gesture = null;
var pointers = new Map();
var spaceDown = false;
var suppressClick = false;
var pressTimer = null;
var lastPointer = null; // world point of the mouse, where pastes land
function cancelPress() {
clearTimeout(pressTimer);
pressTimer = null;
}
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) {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
cancelPress();
if (pointers.size === 2) {
if (gesture && gesture.before) restore(gesture.before);
startPinch();
return;
}
if (pointers.size > 2) return;
settle();
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;
var touch = e.pointerType !== 'mouse';
// Middle and right drag pan; a right click that does not move opens
// the menu (endPointer).
if (e.button === 1 || e.button === 2 || (e.button === 0 && spaceDown)) {
e.preventDefault();
gesture = Object.assign(base, { type: 'pan', menu: e.button === 2, target: e.target, cam: Object.assign({}, cam) });
return;
}
if (e.button !== 0) return;
// A long press on touch opens the menu of what is under the finger.
if (touch && !e.target.closest('.canvas-handle, .canvas-resize')) {
var target = e.target;
pressTimer = setTimeout(function () {
pressTimer = null;
if (!gesture || gesture.moved || pointers.size !== 1) return;
gesture = null;
suppressClick = true;
contextMenu(e.clientX, e.clientY, target);
}, LONG_PRESS);
}
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)) {
gesture = Object.assign(base, { type: 'none' });
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]);
}
gesture = Object.assign(base, { type: 'none' });
return;
}
// The background: a mouse selects with a rubber band, a finger pans.
if (touch) {
if (!additive) select([]);
gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) });
} else {
var keep = additive ? Array.from(selected) : [];
select(keep, additive ? Array.from(selectedEdges) : []);
var box = document.createElement('div');
box.className = 'canvas-marquee';
viewport.appendChild(box);
gesture = Object.assign(base, { type: 'marquee', box: box, keep: keep });
}
});
window.addEventListener('pointermove', function (e) {
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (e.pointerType === 'mouse' && viewport.contains(e.target)) lastPointer = toWorld(e.clientX, 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) < (e.pointerType === 'mouse' ? 4 : 8)) return;
gesture.moved = true;
cancelPress();
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);
cancelPress();
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 'pan':
if (g.menu && !g.moved && e.type === 'pointerup') contextMenu(e.clientX, e.clientY, g.target);
break;
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 && viewport.contains(target)) {
// Dropped on empty space: a new note there, connected.
closeWindow();
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();
select([n.id]);
scheduleSave();
editNode(n.id, true, before);
} 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(); });
// The wheel zooms around the pointer, anywhere: cards crop what they
// cannot show rather than scroll.
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
closeMenu();
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 });
// A double click of the wheel button is back to 100%.
viewport.addEventListener('auxclick', function (e) {
if (e.button !== 1 || e.detail !== 2) return;
var p = viewportPoint(e.clientX, e.clientY);
zoomAt(p.x, p.y, 1);
});
viewport.addEventListener('dblclick', function (e) {
if (readOnly) 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) {
editEdge(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, clipboard ===
function typingInField(e) {
var t = e.target;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
}
// ignored is true for keys and clipboard events meant for something else:
// a field, an edit window, a menu or a modal.
function ignored(e) {
return modalOpen() || typingInField(e) || inWindow(e);
}
// 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() || inWindow(e)) return;
if (e.key === 'E') {
e.preventDefault();
e.stopPropagation();
editSelected();
} else if (e.key === 'M') {
e.stopPropagation();
}
}, true);
function editSelected() {
if (selected.size === 1 && !selectedEdges.size) editNode(Array.from(selected)[0]);
else if (selectedEdges.size === 1 && !selected.size) editEdge(Array.from(selectedEdges)[0]);
}
document.addEventListener('keydown', function (e) {
if (e.key === ' ' && !typingInField(e)) spaceDown = true;
if (e.key === 'Escape' && menu) {
closeMenu();
viewport.focus({ preventScroll: true });
return;
}
if (ignored(e)) return;
var mod = e.ctrlKey || e.metaKey;
var key = e.key.toLowerCase();
var step = e.shiftKey ? 10 : 1;
// Looking around works on a read-only canvas too.
switch (true) {
case e.key === 'Escape':
if (menu) closeMenu();
else if (win) closeWindow();
else select([]);
return;
case mod && key === '0': e.preventDefault(); fit(); return;
case mod && key === '1': e.preventDefault(); zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); return;
case mod && (key === '=' || key === '+'): e.preventDefault(); zoomCenter(1.25); return;
case mod && key === '-': e.preventDefault(); zoomCenter(0.8); return;
case !mod && !e.altKey && key === 'f': e.preventDefault(); zoomToSelection(); return;
case mod && key === 'a':
e.preventDefault();
select(doc.nodes.map(function (n) { return n.id; }));
return;
}
if (readOnly) return;
switch (true) {
case e.key === 'Delete' || e.key === 'Backspace':
e.preventDefault();
deleteSelection();
break;
case mod && !e.shiftKey && key === 'z':
e.preventDefault();
undo();
break;
case mod && (key === 'y' || (e.shiftKey && key === 'z')):
e.preventDefault();
redo();
break;
case mod && key === 'd':
e.preventDefault();
duplicateSelection();
break;
case mod && key === 'g':
e.preventDefault();
if (selected.size) addGroup(null, true);
break;
case e.key === 'F2' || (e.key === 'Enter' && !mod):
e.preventDefault();
editSelected();
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;
});
// A copy goes out as JSON Canvas, so it pastes back as nodes, here or in
// another canvas.
function copy(e) {
if (ignored(e) || !selected.size) return false;
e.clipboardData.setData('text/plain', JSON.stringify(clip(), null, '\t'));
e.preventDefault();
return true;
}
document.addEventListener('copy', copy);
document.addEventListener('cut', function (e) {
if (!readOnly && copy(e)) deleteSelection();
});
document.addEventListener('paste', function (e) {
if (readOnly || ignored(e)) return;
var text = e.clipboardData.getData('text/plain');
if (!text) return;
e.preventDefault();
paste(text, lastPointer || viewCenter());
});
// === Start ===
if (window.MarkdownEditor && editorToolbar) {
MarkdownEditor.bind(editorToolbar, function () { return win && win.editor && win.editor.actions; });
}
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 });
})();