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