// 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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('
'));
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 = '' +
'';
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;
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 });
})();