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