1917 lines
74 KiB
JavaScript
1917 lines
74 KiB
JavaScript
// 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('<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 });
|
||
})();
|