// canvas.js — the JSON Canvas editor (canvas/main.html, canvas.go). // // The document lives here as the parsed file (#canvas-data); every change // mutates it in place, so fields this editor does not know survive the // autosave. Node elements always come from the server: on load they are in // the page, and a node whose content changed is re-rendered by POST ?render. // Only geometry, color and selection are applied locally. Edges are drawn // here, since they follow every drag. // // The handling follows jsoncanvas-desktop. There is no toolbar: everything // is in the context menus (right-click, or a long press on touch) and the // edit windows, which are non-modal and write through as you type. // // Mouse: drag the background to select, middle- or right-drag (or Space+drag) // to pan, wheel zooms, double-click the background for a new note, // double-click a node or edge to edit it, drag a node's side handle onto // another node to connect them. Touch: one finger pans or drags, two fingers // pinch, a long press opens the menu. (function () { var app = document.querySelector('.canvas-app'); if (!app) return; var stage = app.querySelector('.canvas-stage'); var viewport = app.querySelector('.canvas-viewport'); var world = app.querySelector('.canvas-world'); var nodesEl = app.querySelector('.canvas-nodes'); var edgesSvg = app.querySelector('.canvas-edges'); var labelsEl = app.querySelector('.canvas-edge-labels'); var hintEl = app.querySelector('.canvas-hint'); var statusEl = app.querySelector('.canvas-status'); var zoomEl = app.querySelector('.canvas-zoom'); var toolbarHome = app.querySelector('.canvas-editor-toolbar'); var editorToolbar = toolbarHome && toolbarHome.firstElementChild; var readOnly = app.hasAttribute('data-readonly'); var doc = JSON.parse(document.getElementById('canvas-data').textContent); if (!Array.isArray(doc.nodes)) doc.nodes = []; if (!Array.isArray(doc.edges)) doc.edges = []; var version = app.dataset.version; var baseURL = window.location.pathname; var GRID = 10; var MIN_W = 60, MIN_H = 40; var SIDES = ['top', 'right', 'bottom', 'left']; var NORMALS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }; var COLORS = ['Red', 'Orange', 'Yellow', 'Green', 'Cyan', 'Purple']; var LONG_PRESS = 500; var byId = {}; // node id → node var els = {}; // node id → element var elKey = {}; // node id → content key its element shows var htmlCache = {}; // content key → element HTML from the server var selected = new Set(); // node ids var selectedEdges = new Set(); var cam = { x: 0, y: 0, z: 1 }; // === Helpers === function esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function newId() { var a = new Uint8Array(8); crypto.getRandomValues(a); return Array.prototype.map.call(a, function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''); } function snap(v) { return Math.round(v / GRID) * GRID; } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function reindex() { byId = {}; doc.nodes.forEach(function (n) { byId[n.id] = n; }); } function edgeById(id) { return doc.edges.find(function (e) { return e.id === id; }); } function selectedNodes() { return doc.nodes.filter(function (n) { return selected.has(n.id); }); } // contentKey covers everything that changes a node's rendered element // other than geometry and color. function contentKey(n) { return JSON.stringify([n.type, n.text, n.file, n.subpath, n.url, n.label, n.background, n.backgroundStyle]); } function fromHTML(html) { var t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; } function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; } function setColor(target, color, isEl) { if (/^[1-6]$/.test(color || '')) { target.setAttribute('data-color', color); target.style.removeProperty(isEl ? '--node-color' : '--edge-color'); } else if (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color || '')) { target.removeAttribute('data-color'); target.style.setProperty(isEl ? '--node-color' : '--edge-color', color); } else { target.removeAttribute('data-color'); target.style.removeProperty(isEl ? '--node-color' : '--edge-color'); } } function place(el, n) { el.style.left = n.x + 'px'; el.style.top = n.y + 'px'; el.style.width = n.width + 'px'; el.style.height = n.height + 'px'; setColor(el, n.color, true); el.classList.toggle('is-selected', selected.has(n.id)); } // decorate adds the editing handles to a node element. function decorate(el) { if (readOnly) return el; SIDES.forEach(function (side) { var h = document.createElement('span'); h.className = 'canvas-handle'; h.dataset.side = side; el.appendChild(h); }); var r = document.createElement('span'); r.className = 'canvas-resize'; el.appendChild(r); return el; } // title is how a node is named in the edge window. function title(n) { if (!n) return 'missing node'; var t; switch (n.type) { case 'text': t = (n.text || '').trim().split('\n')[0].replace(/^#+\s*/, ''); break; case 'file': t = (n.file || '').split('/').pop(); break; case 'link': t = n.label || n.url; break; case 'group': t = n.label; break; } t = t || n.type; return t.length > 40 ? t.slice(0, 39) + '…' : t; } function modalOpen() { return !!document.querySelector('.modal-backdrop'); } // === Camera === var camKey = 'canvas-cam:' + baseURL; function applyCam() { world.style.transform = 'translate(' + cam.x + 'px,' + cam.y + 'px) scale(' + cam.z + ')'; world.style.setProperty('--z', cam.z); var g = 20 * cam.z; viewport.style.backgroundSize = g + 'px ' + g + 'px'; viewport.style.backgroundPosition = cam.x + 'px ' + cam.y + 'px'; viewport.classList.toggle('is-far', cam.z < 0.4); if (zoomEl) zoomEl.textContent = Math.round(cam.z * 100) + '%'; try { sessionStorage.setItem(camKey, JSON.stringify(cam)); } catch (e) {} } function viewportPoint(clientX, clientY) { var r = viewport.getBoundingClientRect(); return { x: clientX - r.left, y: clientY - r.top }; } function toWorld(clientX, clientY) { var p = viewportPoint(clientX, clientY); return { x: (p.x - cam.x) / cam.z, y: (p.y - cam.y) / cam.z }; } function zoomAt(px, py, z) { z = clamp(z, 0.1, 4); cam.x = px - (px - cam.x) * z / cam.z; cam.y = py - (py - cam.y) * z / cam.z; cam.z = z; applyCam(); } function zoomCenter(factor) { zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, cam.z * factor); } function bounds(nodes) { if (!nodes.length) return null; var b = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }; nodes.forEach(function (n) { b.x1 = Math.min(b.x1, n.x); b.y1 = Math.min(b.y1, n.y); b.x2 = Math.max(b.x2, n.x + n.width); b.y2 = Math.max(b.y2, n.y + n.height); }); return b; } // fit shows the given nodes (all of them by default), never above 100%. function fit(nodes) { var w = viewport.clientWidth, h = viewport.clientHeight; var b = bounds(nodes || doc.nodes); if (!b) { cam = { x: w / 2, y: h / 2, z: 1 }; } else { var pad = 40; var z = clamp(Math.min((w - 2 * pad) / (b.x2 - b.x1), (h - 2 * pad) / (b.y2 - b.y1), 1), 0.1, 1); cam = { x: (w - (b.x2 - b.x1) * z) / 2 - b.x1 * z, y: (h - (b.y2 - b.y1) * z) / 2 - b.y1 * z, z: z, }; } applyCam(); } function zoomToSelection() { var nodes = selectedNodes(); fit(nodes.length ? nodes : null); } // The point new nodes go to: the middle of the view. function viewCenter() { return { x: (viewport.clientWidth / 2 - cam.x) / cam.z, y: (viewport.clientHeight / 2 - cam.y) / cam.z, }; } // === Rendering === var pending = {}; // content key → in-flight render // renderNode fetches the element for n's current content and swaps it in // once it arrives, unless the node changed again in the meantime. function renderNode(n) { var key = contentKey(n); if (pending[key]) return pending[key]; var p = fetch(baseURL + '?render', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(n), }).then(function (res) { if (!res.ok) throw new Error('render failed (' + res.status + ')'); return res.text(); }).then(function (html) { htmlCache[key] = html; var cur = byId[n.id]; if (cur && contentKey(cur) === key && elKey[n.id] !== key) { swapElement(cur, key); if (cur.type === 'text') growToFit(cur); renderEdges(); } }).catch(function (err) { setStatus('error', err.message); }).then(function () { delete pending[key]; }); pending[key] = p; return p; } // whenRendered runs fn once n's element shows its current content. function whenRendered(n, fn) { if (elKey[n.id] === contentKey(n)) fn(); else renderNode(n).then(fn); } function swapElement(n, key) { var el = decorate(fromHTML(htmlCache[key])); var old = els[n.id]; if (old) old.replaceWith(el); else nodesEl.appendChild(el); els[n.id] = el; elKey[n.id] = key; place(el, n); } // growToFit makes a text node tall enough for its content. It never // shrinks a node: the size is the user's (Fit to text does both). function growToFit(n) { var el = els[n.id]; var body = el && el.querySelector('.canvas-node-body'); if (!body) return; var extra = body.scrollHeight - body.clientHeight; if (extra > 1) { n.height = snap(n.height + extra + GRID / 2); place(el, n); scheduleSave(); } } // fitHeight is the height n's card needs to show all of its text and no // more, or null for a card whose content has no height of its own. function fitHeight(n) { var el = els[n.id]; var body = el && el.querySelector('.canvas-node-body.content'); if (!body || n.type === 'group') return null; var h = el.style.height; el.style.height = 'auto'; var natural = el.offsetHeight; el.style.height = h; return Math.max(MIN_H, Math.ceil(natural / GRID) * GRID); } // fitHeights sizes the given cards to their text; it reports whether any // of them changed. function fitHeights(ids) { var changed = false; ids.forEach(function (id) { var n = byId[id], h = n && fitHeight(n); if (h && h !== n.height) { n.height = h; changed = true; } }); return changed; } // syncDOM brings the node elements in line with doc.nodes: creates, // removes, reorders (array order is z-order), re-renders changed content. function syncDOM() { reindex(); var keep = {}; doc.nodes.forEach(function (n, i) { keep[n.id] = true; var key = contentKey(n); var el = els[n.id]; if (elKey[n.id] !== key) { if (htmlCache[key] !== undefined) { swapElement(n, key); el = els[n.id]; } else { if (!el) { // A placeholder until the server's element arrives. el = decorate(fromHTML('
')); els[n.id] = el; elKey[n.id] = null; } renderNode(n); } } place(el, n); if (nodesEl.children[i] !== el) nodesEl.insertBefore(el, nodesEl.children[i] || null); }); Object.keys(els).forEach(function (id) { if (keep[id]) return; els[id].remove(); delete els[id]; delete elKey[id]; selected.delete(id); }); selectedEdges.forEach(function (id) { if (!edgeById(id)) selectedEdges.delete(id); }); if (hintEl) hintEl.hidden = doc.nodes.length > 0; renderEdges(); } // --- Edges --- function center(n) { return { x: n.x + n.width / 2, y: n.y + n.height / 2 }; } function anchor(n, side) { switch (side) { case 'top': return { x: n.x + n.width / 2, y: n.y }; case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height }; case 'left': return { x: n.x, y: n.y + n.height / 2 }; default: return { x: n.x + n.width, y: n.y + n.height / 2 }; } } // sideToward picks the side of n facing point p. function sideToward(n, p) { var c = center(n); var dx = (p.x - c.x) / Math.max(n.width, 1), dy = (p.y - c.y) / Math.max(n.height, 1); if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left'; return dy > 0 ? 'bottom' : 'top'; } // curve returns the bezier of an edge from p1 (leaving side s1) to p2 // (entering side s2; null for a free end like the pointer). function curve(p1, s1, p2, s2) { var d = Math.hypot(p2.x - p1.x, p2.y - p1.y); var off = clamp(d / 2, 30, 150); var n1 = NORMALS[s1], n2 = s2 ? NORMALS[s2] : [0, 0]; return { p1: p1, c1: { x: p1.x + n1[0] * off, y: p1.y + n1[1] * off }, c2: { x: p2.x + n2[0] * off, y: p2.y + n2[1] * off }, p2: p2, }; } function curvePath(c) { return 'M' + c.p1.x + ' ' + c.p1.y + 'C' + c.c1.x + ' ' + c.c1.y + ' ' + c.c2.x + ' ' + c.c2.y + ' ' + c.p2.x + ' ' + c.p2.y; } // arrow draws an arrowhead with its tip at p, pointing from control // point c toward p. function arrow(p, c) { var dx = p.x - c.x, dy = p.y - c.y; var len = Math.hypot(dx, dy) || 1; dx /= len; dy /= len; var s = 12, w = 6; var bx = p.x - dx * s, by = p.y - dy * s; return 'M' + p.x + ' ' + p.y + 'L' + (bx - dy * w) + ' ' + (by + dx * w) + 'L' + (bx + dy * w) + ' ' + (by - dx * w) + 'Z'; } function edgeGeometry(e) { var a = byId[e.fromNode], b = byId[e.toNode]; if (!a || !b) return null; var s1 = e.fromSide || sideToward(a, center(b)); var s2 = e.toSide || sideToward(b, center(a)); return curve(anchor(a, s1), s1, anchor(b, s2), s2); } var draft = null; // the connection being dragged: { from, side, to: {x,y} } function renderEdges() { var parts = []; var labels = []; var box = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }; function grow(p) { box.x1 = Math.min(box.x1, p.x); box.y1 = Math.min(box.y1, p.y); box.x2 = Math.max(box.x2, p.x); box.y2 = Math.max(box.y2, p.y); } doc.edges.forEach(function (e) { var c = edgeGeometry(e); if (!c) return; [c.p1, c.c1, c.c2, c.p2].forEach(grow); var d = curvePath(c); var preset = /^[1-6]$/.test(e.color || '') ? ' data-color="' + e.color + '"' : ''; var hex = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(e.color || '') ? '--edge-color:' + e.color + ';' : ''; var g = '' + ''; if ((e.toEnd || 'arrow') === 'arrow') g += ''; if ((e.fromEnd || 'none') === 'arrow') g += ''; parts.push(g + ''); if (e.label) { var mx = (c.p1.x + 3 * c.c1.x + 3 * c.c2.x + c.p2.x) / 8; var my = (c.p1.y + 3 * c.c1.y + 3 * c.c2.y + c.p2.y) / 8; labels.push('
' + esc(e.label) + '
'); } }); if (draft) { var from = byId[draft.from]; var c = curve(anchor(from, draft.side), draft.side, draft.to, null); [c.p1, c.c1, c.c2, c.p2].forEach(grow); parts.push(''); } if (box.x1 === Infinity) { edgesSvg.style.width = edgesSvg.style.height = '0'; edgesSvg.innerHTML = ''; } else { var m = 40; var x = box.x1 - m, y = box.y1 - m; edgesSvg.style.left = x + 'px'; edgesSvg.style.top = y + 'px'; edgesSvg.style.width = (box.x2 - box.x1 + 2 * m) + 'px'; edgesSvg.style.height = (box.y2 - box.y1 + 2 * m) + 'px'; edgesSvg.innerHTML = '' + parts.join('') + ''; } labelsEl.innerHTML = labels.join(''); } // === Selection === function select(ids, edgeIds) { selected = new Set(ids || []); selectedEdges = new Set(edgeIds || []); Object.keys(els).forEach(function (id) { els[id].classList.toggle('is-selected', selected.has(id)); }); renderEdges(); } function toggleSelected(id) { var s = new Set(selected); if (s.has(id)) s.delete(id); else s.add(id); select(Array.from(s), Array.from(selectedEdges)); } // === Undo, mutations === var undoStack = [], redoStack = []; function snapshot() { return JSON.stringify({ nodes: doc.nodes, edges: doc.edges }); } function pushUndo(before) { if (before === snapshot()) return; undoStack.push(before); if (undoStack.length > 200) undoStack.shift(); redoStack = []; } // mutate runs fn as one undoable change. function mutate(fn) { if (readOnly) return; settle(); var before = snapshot(); fn(); pushUndo(before); syncDOM(); scheduleSave(); } function restore(s) { var d = JSON.parse(s); doc.nodes = d.nodes; doc.edges = d.edges; syncDOM(); select(Array.from(selected).filter(function (id) { return byId[id]; })); scheduleSave(); } function undo() { closeWindow(); if (!undoStack.length) return; redoStack.push(snapshot()); restore(undoStack.pop()); } function redo() { closeWindow(); if (!redoStack.length) return; undoStack.push(snapshot()); restore(redoStack.pop()); } // === Saving === // Every change saves itself shortly after. A save carries the version it // is based on; the server refuses it (409) when the file changed // elsewhere, and from then on nothing is saved until a reload. var saveTimer = null, saving = false, dirty = false, failed = false, conflict = false; function setStatus(state, msg) { if (!statusEl) return; var text = { saved: 'Saved', unsaved: 'Unsaved', saving: 'Saving…', error: 'Not saved: ' + (msg || 'error'), conflict: 'Changed elsewhere — reload to continue', }[state]; statusEl.textContent = text; statusEl.title = msg || ''; statusEl.classList.toggle('is-error', state === 'error' || state === 'conflict'); } // incomplete is true while the document would be refused: a link node // just added has no URL until its window has one typed in. function incomplete() { return doc.nodes.some(function (n) { return n.type === 'link' && !n.url; }); } function scheduleSave() { if (readOnly || conflict) return; dirty = true; if (!saving) setStatus('unsaved'); clearTimeout(saveTimer); saveTimer = setTimeout(save, 800); } function save() { clearTimeout(saveTimer); saveTimer = null; if (saving || !dirty || conflict || incomplete()) return; saving = true; dirty = false; setStatus('saving'); var retry = false; fetch(baseURL + '?save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Canvas-Version': version }, body: JSON.stringify(doc), }).then(function (res) { if (res.ok) { version = res.headers.get('X-Canvas-Version') || version; failed = false; return; } return res.text().then(function (msg) { failed = true; dirty = true; if (res.status === 409) { conflict = true; setStatus('conflict', msg); } else { // Server errors may pass; a refused document would only // be refused again, so that waits for the next change. retry = res.status >= 500; setStatus('error', msg.trim() || String(res.status)); } }); }).catch(function () { failed = true; dirty = true; retry = true; setStatus('error', 'network error'); }).then(function () { saving = false; if (conflict) return; if (retry) { saveTimer = setTimeout(save, 5000); } else if (dirty && !failed) { save(); } else if (!dirty) { setStatus('saved'); } }); } // Leaving the page: send what is unsaved with keepalive, which outlives // the page (up to 64 KB). Only a save that cannot go out that way asks // the user to stay. window.addEventListener('beforeunload', function (e) { settle(); if (!dirty || conflict) return; var body = JSON.stringify(doc); if (!saving && !incomplete() && body.length < 60000) { clearTimeout(saveTimer); dirty = false; saving = true; fetch(baseURL + '?save', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json', 'X-Canvas-Version': version }, body: body, }); return; } e.preventDefault(); e.returnValue = ''; }); // A tab going to the background may never come back (mobile). document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden' && dirty && !saving) save(); }); // === Edit windows === // One window at a time, for one node or edge. It is not modal: the canvas // stays live behind it, and what is typed shows on the card as it is // typed. Everything done in one window is one undo step (settle). var win = null; // { id, edge, el, before, isNew, editor } var renderTimer = null; // settle closes the open window's undo step, so that whatever happens // next on the canvas is a step of its own. function settle() { if (!win) return; if (win.before !== snapshot()) pushUndo(win.before); win.before = snapshot(); } // live applies a change made in the window: the card follows shortly, // the edges at once. function live() { renderEdges(); clearTimeout(renderTimer); renderTimer = setTimeout(syncDOM, 250); scheduleSave(); } function inWindow(e) { return !!(e.target && e.target.closest && e.target.closest('.canvas-window, .canvas-menu')); } // openWindow shows a window beside the card of opts.id (or in the middle // of the view): opts.title, opts.body, opts.buttons [{ label, run }], // opts.wide for the text editor, opts.before the undo state it started // from, opts.isNew for a node that goes away if left empty. function openWindow(opts) { closeWindow(); var w = el('div', 'canvas-window panel panel-floating' + (opts.wide ? ' is-wide' : '')); w.setAttribute('role', 'dialog'); var header = el('div', 'panel-header'); header.appendChild(el('span', 'truncate', opts.title)); var x = el('button', 'btn btn-tool', '×'); x.type = 'button'; x.title = 'Close (Esc)'; x.addEventListener('click', closeWindow); header.appendChild(x); var body = el('div', 'panel-body'); body.appendChild(opts.body); var footer = el('div', 'panel-footer'); (opts.buttons || []).concat([{ label: 'CLOSE', run: closeWindow }]).forEach(function (b) { var btn = el('button', 'btn', b.label); btn.type = 'button'; if (b.title) btn.title = b.title; btn.addEventListener('click', b.run); footer.appendChild(btn); }); w.appendChild(header); w.appendChild(body); w.appendChild(footer); stage.appendChild(w); w.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !e.defaultPrevented && !modalOpen()) { e.preventDefault(); closeWindow(); } }); dragByHeader(w, header); win = { id: opts.id, edge: opts.edge, el: w, before: opts.before || snapshot(), isNew: !!opts.isNew, editor: null, }; placeWindow(w, opts.edge ? null : opts.id); if (!opts.wide) { var first = body.querySelector('input, select'); if (first) { first.focus(); if (first.select) first.select(); } } return win; } // placeWindow puts the window beside the card, on whichever side has // more room, within the stage. Small screens dock it to the bottom (CSS). function placeWindow(w, id) { if (window.matchMedia('(max-width: 600px)').matches) return; var s = stage.getBoundingClientRect(); var gap = 16; var ww = w.offsetWidth, wh = w.offsetHeight; var left = (s.width - ww) / 2, top = gap; var card = id && els[id] && els[id].getBoundingClientRect(); if (card) { var l = card.left - s.left, r = card.right - s.left; left = s.width - r >= l ? r + gap : l - gap - ww; top = card.top - s.top; } w.style.left = clamp(left, gap, Math.max(gap, s.width - ww - gap)) + 'px'; w.style.top = clamp(top, gap, Math.max(gap, s.height - wh - gap)) + 'px'; } function dragByHeader(w, header) { header.addEventListener('pointerdown', function (e) { if (e.button !== 0 || e.target.closest('button') || window.matchMedia('(max-width: 600px)').matches) return; e.preventDefault(); var s = stage.getBoundingClientRect(); var ox = w.offsetLeft - e.clientX, oy = w.offsetTop - e.clientY; function move(ev) { w.style.left = clamp(ev.clientX + ox, 0, s.width - w.offsetWidth) + 'px'; w.style.top = clamp(ev.clientY + oy, 0, s.height - header.offsetHeight) + 'px'; } function up() { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); } window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }); } function closeWindow() { if (!win) return; var w = win; if (w.editor) { w.editor.destroy(); if (editorToolbar) toolbarHome.appendChild(editorToolbar); } clearTimeout(renderTimer); w.el.remove(); var n = byId[w.id]; // A new node left empty was a misclick: it goes without a trace. if (w.isNew && n && (n.type === 'text' ? !(n.text || '').trim() : n.type === 'link' && !n.url)) { doc.nodes = doc.nodes.filter(function (x) { return x.id !== n.id; }); doc.edges = doc.edges.filter(function (x) { return x.fromNode !== n.id && x.toNode !== n.id; }); } settle(); win = null; syncDOM(); scheduleSave(); viewport.focus({ preventScroll: true }); } // --- Window parts --- function field(label, control) { var f = el('label', 'canvas-field'); f.appendChild(el('span', 'canvas-field-label', label)); f.appendChild(control); return f; } function textInput(value, placeholder, onInput) { var i = el('input', 'input'); i.type = 'text'; i.value = value || ''; i.placeholder = placeholder || ''; i.addEventListener('input', function () { onInput(i.value); }); return i; } function selectInput(options, value, onChange) { var s = el('select', 'input'); options.forEach(function (o) { var opt = el('option', '', o[1]); opt.value = o[0]; s.appendChild(opt); }); s.value = value || ''; s.addEventListener('change', function () { onChange(s.value); }); return s; } function smallButton(label, run) { var b = el('button', 'btn btn-tool', label); b.type = 'button'; b.addEventListener('click', run); return b; } // set writes v to n[key], or drops the key for an empty value. function set(n, key, v) { if (v) n[key] = v; else delete n[key]; } // --- The windows --- // editNode is the double-click / Enter / F2 action for every node type. function editNode(id, isNew, before) { var n = byId[id]; if (!n || readOnly) return; if (win && win.id === id && !win.edge) return; switch (n.type) { case 'text': editText(n, isNew, before); break; case 'link': editLink(n, isNew, before); break; case 'group': editGroup(n); break; case 'file': editFile(n); break; } } function editText(n, isNew, before) { if (!window.MarkdownEditor) return; var box = el('div', 'canvas-window-editor'); if (editorToolbar) box.appendChild(editorToolbar); var mount = el('div', 'canvas-window-cm'); box.appendChild(mount); var w = openWindow({ title: 'Edit text', id: n.id, body: box, wide: true, isNew: isNew, before: before, buttons: [{ label: 'CLOSE & FIT', title: 'Close, and size the card to its text', run: function () { var cur = byId[w.id]; if (!cur) return closeWindow(); clearTimeout(renderTimer); syncDOM(); whenRendered(cur, function () { fitHeights([cur.id]); closeWindow(); }); }, }], }); var closeKey = function () { if (document.querySelector('.cm-tooltip-autocomplete')) return false; closeWindow(); return true; }; w.editor = MarkdownEditor.create(mount, { doc: n.text || '', keymap: [{ key: 'Escape', run: closeKey }, { key: 'Mod-Enter', run: closeKey }], onChange: function () { var cur = byId[w.id]; if (!cur || win !== w) return; cur.text = w.editor.getValue(); live(); }, }); } function editLink(n, isNew, before) { var box = el('div', 'canvas-fields'); box.appendChild(field('URL', textInput(n.url, 'https://', function (v) { // A link needs its URL: clearing the field keeps the last one. if (!v.trim()) return; n.url = v.trim(); live(); }))); box.appendChild(field('Display text', textInput(n.label, 'Shown instead of the URL', function (v) { set(n, 'label', v); live(); }))); openWindow({ title: 'Edit link', id: n.id, body: box, isNew: isNew, before: before }); } function editGroup(n) { var box = el('div', 'canvas-fields'); box.appendChild(field('Label', textInput(n.label, 'Unnamed group', function (v) { set(n, 'label', v); live(); }))); var path = el('span', 'muted truncate', n.background || 'none'); var row = el('div', 'canvas-field-row'); row.appendChild(path); row.appendChild(smallButton('CHOOSE…', function () { pickFile('Background image', function (rel) { n.background = rel; path.textContent = rel; live(); }); })); row.appendChild(smallButton('CLEAR', function () { delete n.background; path.textContent = 'none'; live(); })); box.appendChild(field('Background image', row)); box.appendChild(field('Fitting', selectInput( [['', 'default'], ['cover', 'cover'], ['ratio', 'ratio'], ['repeat', 'repeat']], n.backgroundStyle, function (v) { set(n, 'backgroundStyle', v); live(); }))); openWindow({ title: 'Edit group', id: n.id, body: box }); } function editFile(n) { var box = el('div', 'canvas-fields'); var path = el('span', 'truncate', n.file); var row = el('div', 'canvas-field-row'); row.appendChild(path); row.appendChild(smallButton('CHANGE…', function () { pickFile('Pick a file or page', function (rel) { n.file = rel; path.textContent = rel; live(); }); })); box.appendChild(field('File', row)); box.appendChild(field('Subpath', textInput(n.subpath, '#heading', function (v) { set(n, 'subpath', v.trim()); live(); }))); openWindow({ title: 'Edit ' + (n.file || '').split('/').pop(), id: n.id, body: box }); } function editEdge(id) { var e = edgeById(id); if (!e || readOnly) return; select([], [id]); var box = el('div', 'canvas-fields'); box.appendChild(el('span', 'muted', title(byId[e.fromNode]) + ' → ' + title(byId[e.toNode]))); box.appendChild(field('Label', textInput(e.label, 'Shown on the line', function (v) { set(e, 'label', v); live(); }))); var sides = [['', 'auto'], ['top', 'top'], ['right', 'right'], ['bottom', 'bottom'], ['left', 'left']]; function ends(def) { return [['', 'default (' + def + ')'], ['none', 'none'], ['arrow', 'arrow']]; } function pair(a, b) { var r = el('div', 'canvas-field-pair'); r.appendChild(a); r.appendChild(b); return r; } function choice(key, options) { return selectInput(options, e[key], function (v) { set(e, key, v); live(); }); } box.appendChild(pair(field('From side', choice('fromSide', sides)), field('Start', choice('fromEnd', ends('none'))))); box.appendChild(pair(field('To side', choice('toSide', sides)), field('End', choice('toEnd', ends('arrow'))))); openWindow({ title: 'Edit edge', id: id, edge: true, body: box }); } function pageFolder() { return decodeURIComponent(baseURL.slice(0, baseURL.lastIndexOf('/'))) || '/'; } function pickFile(title, onPick) { openTreePicker({ title: title, mode: 'any', initialPath: pageFolder(), allowRoot: false, confirmLabel: 'SELECT', onSelect: function (absPath) { onPick(absPath.replace(/^\/+/, '')); } }); } // === Commands === // addNode puts n centered on at (the middle of the view by default). function addNode(n, at) { n.id = newId(); var c = at || viewCenter(); n.x = snap(c.x - n.width / 2); n.y = snap(c.y - n.height / 2); mutate(function () { if (n.type === 'group') doc.nodes.unshift(n); else doc.nodes.push(n); }); select([n.id]); return n; } // addText adds a note and opens its window. Creating and writing it is // one undo step, and a note left empty disappears (closeWindow). function addText(at) { closeWindow(); var before = snapshot(); var n = addNode({ type: 'text', text: '', width: 260, height: 80 }, at); undoStack.pop(); editNode(n.id, true, before); } function addLink(at) { closeWindow(); var before = snapshot(); var n = addNode({ type: 'link', url: '', width: 320, height: 70 }, at); undoStack.pop(); editNode(n.id, true, before); } function addFile(at) { pickFile('Pick a file or page', function (rel) { var image = /\.(png|jpe?g|gif|webp|svg)$/i.test(rel); var card = !image && !/\.md$/i.test(rel) && /\.[^\/]+$/.test(rel); addNode({ type: 'file', file: rel, width: card ? 300 : 400, height: card ? 60 : image ? 300 : 400 }, at); }); } // addGroup puts a group around the selection, or an empty one at the // given point or in view. function addGroup(at, around) { var b = bounds(around ? selectedNodes() : []); var n = { type: 'group', label: 'Group', width: 400, height: 300 }; if (b) { var pad = 30; n.width = b.x2 - b.x1 + 2 * pad; n.height = b.y2 - b.y1 + 2 * pad; at = { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2 }; } n = addNode(n, at); editNode(n.id); } function deleteSelection() { if (!selected.size && !selectedEdges.size) return; if (win && (win.edge ? selectedEdges.has(win.id) : selected.has(win.id))) closeWindow(); mutate(function () { doc.nodes = doc.nodes.filter(function (n) { return !selected.has(n.id); }); doc.edges = doc.edges.filter(function (e) { return !selectedEdges.has(e.id) && !selected.has(e.fromNode) && !selected.has(e.toNode); }); }); select([]); } // insert adds copies of nodes (and the edges between them) with new ids, // their box centered on at, and selects them. function insert(nodes, edges, at) { var b = bounds(nodes); if (!b) return; var dx = snap(at.x - (b.x1 + b.x2) / 2), dy = snap(at.y - (b.y1 + b.y2) / 2); var map = {}; var copies = nodes.map(function (n) { var c = JSON.parse(JSON.stringify(n)); c.id = newId(); c.x += dx; c.y += dy; map[n.id] = c.id; return c; }); mutate(function () { doc.nodes = doc.nodes.concat(copies); edges.filter(function (e) { return map[e.fromNode] && map[e.toNode]; }).forEach(function (e) { var c = JSON.parse(JSON.stringify(e)); c.id = newId(); c.fromNode = map[e.fromNode]; c.toNode = map[e.toNode]; doc.edges.push(c); }); }); select(copies.map(function (c) { return c.id; })); } // clip is the selection as a JSON Canvas document: groups take what is // inside them along, and edges come when both ends do. function clip() { var ids = new Set(movedSet(Array.from(selected))); return { nodes: doc.nodes.filter(function (n) { return ids.has(n.id); }), edges: doc.edges.filter(function (e) { return ids.has(e.fromNode) && ids.has(e.toNode); }), }; } function duplicateSelection() { if (!selected.size) return; var c = clip(), b = bounds(c.nodes); insert(c.nodes, c.edges, { x: (b.x1 + b.x2) / 2 + 3 * GRID, y: (b.y1 + b.y2) / 2 + 3 * GRID }); } // paste takes JSON Canvas (a copy from this editor or another), a URL // (a link node) or any other text (a note). function paste(text, at) { var data = null; try { data = JSON.parse(text); } catch (e) {} if (data && Array.isArray(data.nodes) && data.nodes.length && data.nodes.every(function (n) { return n && typeof n.id === 'string' && typeof n.type === 'string' && isFinite(n.x) && isFinite(n.y); })) { insert(data.nodes, Array.isArray(data.edges) ? data.edges : [], at); } else if (/^https?:\/\/\S+$/.test(text.trim())) { addNode({ type: 'link', url: text.trim(), width: 320, height: 70 }, at); } else if (text.trim()) { addNode({ type: 'text', text: text, width: 260, height: 80 }, at); } } function paint(nodeIds, edgeIds, color) { mutate(function () { doc.nodes.forEach(function (n) { if (nodeIds.has(n.id)) set(n, 'color', color); }); doc.edges.forEach(function (e) { if (edgeIds.has(e.id)) set(e, 'color', color); }); }); } function reverseEdge(e) { mutate(function () { var t = [e.fromNode, e.fromSide, e.fromEnd]; e.fromNode = e.toNode; e.fromSide = e.toSide; e.fromEnd = e.toEnd; e.toNode = t[0]; e.toSide = t[1]; e.toEnd = t[2]; ['fromSide', 'fromEnd', 'toSide', 'toEnd'].forEach(function (k) { if (e[k] === undefined) delete e[k]; }); }); } // raise moves the selection to the front (end of the array) or back. function raise(front) { mutate(function () { var sel = doc.nodes.filter(function (n) { return selected.has(n.id); }); var rest = doc.nodes.filter(function (n) { return !selected.has(n.id); }); doc.nodes = front ? rest.concat(sel) : sel.concat(rest); }); } function align(how) { var nodes = selectedNodes(), b = bounds(nodes); if (nodes.length < 2) return; mutate(function () { nodes.forEach(function (n) { switch (how) { case 'left': n.x = b.x1; break; case 'centre': n.x = Math.round((b.x1 + b.x2 - n.width) / 2); break; case 'right': n.x = b.x2 - n.width; break; case 'top': n.y = b.y1; break; case 'middle': n.y = Math.round((b.y1 + b.y2 - n.height) / 2); break; case 'bottom': n.y = b.y2 - n.height; break; } }); }); } function fitSelection() { mutate(function () { fitHeights(Array.from(selected)); }); } function nudge(dx, dy) { if (!selected.size) return; mutate(function () { movedSet(Array.from(selected)).forEach(function (id) { byId[id].x += dx; byId[id].y += dy; }); }); } // movedSet is what moves with the given nodes: groups take the nodes // inside them along. function movedSet(ids) { var out = new Set(ids); ids.forEach(function (id) { var g = byId[id]; if (!g || g.type !== 'group') return; doc.nodes.forEach(function (n) { if (n.id !== g.id && n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height) out.add(n.id); }); }); return Array.from(out); } // openTarget is where a file or link node leads: the link the server put // on its card. function openTarget(id) { var a = els[id] && els[id].querySelector('.canvas-node-label a, a.canvas-card, a.canvas-media'); return a ? a.href : null; } // === Context menus === var menu = null; // openMenu shows items at a client point. An item is '-' (a separator), // { label, run, danger, disabled }, { colors: current, run(color) } or // { row: label, items: [{ label, run }] }. function openMenu(clientX, clientY, items) { closeMenu(); var m = el('div', 'canvas-menu panel panel-floating'); m.setAttribute('role', 'menu'); var last = '-'; items.forEach(function (item) { if (!item || (item === '-' && last === '-')) return; last = item; if (item === '-') { m.appendChild(el('div', 'canvas-menu-sep')); } else if (item.colors !== undefined) { m.appendChild(colorRow(item.colors, item.run)); } else if (item.row) { var r = el('div', 'canvas-menu-row'); r.appendChild(el('span', 'muted', item.row)); item.items.forEach(function (sub) { r.appendChild(menuButton(sub, 'btn btn-tool')); }); m.appendChild(r); } else { m.appendChild(menuButton(item, 'btn btn-tool btn-block' + (item.danger ? ' danger' : ''))); } }); if (last === '-' && m.lastChild) m.lastChild.remove(); if (!m.firstChild) return; app.appendChild(m); var x = clamp(clientX, 4, window.innerWidth - m.offsetWidth - 4); var y = clientY + m.offsetHeight > window.innerHeight - 4 ? clientY - m.offsetHeight : clientY; m.style.left = x + 'px'; m.style.top = clamp(y, 4, window.innerHeight - m.offsetHeight - 4) + 'px'; menu = m; var first = m.querySelector('button:not(:disabled)'); if (first) first.focus({ preventScroll: true }); } function menuButton(item, cls) { var b = el('button', cls, item.label); b.type = 'button'; b.setAttribute('role', 'menuitem'); b.disabled = !!item.disabled; if (item.title) b.title = item.title; b.addEventListener('click', function () { closeMenu(); item.run(); }); return b; } // colorRow is the six presets, none, and a picker for any other color. // current is the color to mark ('' for none), null for no mark. function colorRow(current, run) { var r = el('div', 'canvas-menu-row canvas-menu-colors'); r.appendChild(el('span', 'muted', 'Color')); function swatch(value, name) { var b = el('button', 'canvas-swatch' + (current === value ? ' is-current' : '')); b.type = 'button'; b.title = name; b.setAttribute('aria-label', name); if (value) b.setAttribute('data-color', value); b.addEventListener('click', function () { closeMenu(); run(value); }); return b; } r.appendChild(swatch('', 'None')); COLORS.forEach(function (name, i) { r.appendChild(swatch(String(i + 1), name)); }); var custom = el('input', 'canvas-swatch canvas-swatch-custom' + (/^#/.test(current || '') ? ' is-current' : '')); custom.type = 'color'; custom.title = 'Custom color'; custom.value = /^#[0-9a-fA-F]{6}$/.test(current || '') ? current : '#c48401'; custom.addEventListener('change', function () { closeMenu(); run(custom.value); }); r.appendChild(custom); return r; } function closeMenu() { if (!menu) return; menu.remove(); menu = null; } document.addEventListener('pointerdown', function (e) { if (menu && !menu.contains(e.target)) closeMenu(); }, true); window.addEventListener('resize', closeMenu); window.addEventListener('blur', closeMenu); // contextMenu opens the menu for whatever is at target: a node, an edge // or the bare canvas. function contextMenu(clientX, clientY, target) { if (modalOpen()) return; var nodeEl = target.closest && target.closest('.canvas-node'); var edgeEl = target.closest && target.closest('.canvas-edge, .canvas-edge-label'); if (nodeEl && byId[nodeEl.dataset.id]) { var id = nodeEl.dataset.id; if (!selected.has(id)) select([id]); openMenu(clientX, clientY, nodeMenu(byId[id])); } else if (edgeEl && edgeById(edgeEl.dataset.id)) { select([], [edgeEl.dataset.id]); openMenu(clientX, clientY, edgeMenu(edgeById(edgeEl.dataset.id))); } else { openMenu(clientX, clientY, backgroundMenu(toWorld(clientX, clientY))); } } // backgroundMenu: everything that can be added, landing at p. function backgroundMenu(p) { var view = [ { label: 'Zoom to fit', run: function () { fit(); } }, { label: 'Actual size', run: function () { zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); } }, ]; if (readOnly) return view; return [ { label: 'Text', run: function () { addText(p); } }, { label: 'File…', run: function () { addFile(p); } }, { label: 'Link', run: function () { addLink(p); } }, '-', { label: 'Group', run: function () { addGroup(p); } }, '-', { label: 'Undo', run: undo, disabled: !undoStack.length }, { label: 'Redo', run: redo, disabled: !redoStack.length }, { label: 'Select all', run: function () { select(doc.nodes.map(function (n) { return n.id; })); }, disabled: !doc.nodes.length }, '-', ].concat(view); } // nodeMenu: what can be done to the selection, which n is part of. function nodeMenu(n) { var count = selected.size; var single = count <= 1; var target = (n.type === 'file' || n.type === 'link') && openTarget(n.id); var open = target && { label: n.type === 'link' ? 'Open in new tab' : 'Open', run: function () { if (n.type === 'link') window.open(target, '_blank', 'noopener'); else window.location.href = target; }, }; if (readOnly) return single && open ? [open] : []; var kind = []; if (single) { if (open) kind.push(open); if (n.type === 'file') { kind.push({ label: 'Change file…', run: function () { pickFile('Pick a file or page', function (rel) { mutate(function () { n.file = rel; delete n.subpath; }); }); } }); } if (n.type === 'group') { kind.push({ label: 'Background image…', run: function () { pickFile('Background image', function (rel) { mutate(function () { n.background = rel; }); }); } }); kind.push({ label: 'Clear background', disabled: !n.background, run: function () { mutate(function () { delete n.background; }); } }); } } var canFit = selectedNodes().some(function (x) { return fitHeight(x) !== null; }); return [ single && { label: 'Edit…', run: function () { editNode(n.id); } }, ].concat(kind, [ { label: 'Fit to text', run: fitSelection, disabled: !canFit, title: 'Size the card to show all of its text, and no more' }, '-', { colors: single ? n.color || '' : null, run: function (c) { paint(selected, new Set(), c); } }, '-', { label: 'Duplicate', run: duplicateSelection }, { label: 'Put in a group', run: function () { addGroup(null, true); } }, { label: 'Bring to front', run: function () { raise(true); } }, { label: 'Send to back', run: function () { raise(false); } }, count >= 2 && { row: 'Align', items: [ { label: 'LEFT', run: function () { align('left'); } }, { label: 'CENTRE', run: function () { align('centre'); } }, { label: 'RIGHT', run: function () { align('right'); } }, ] }, count >= 2 && { row: '', items: [ { label: 'TOP', run: function () { align('top'); } }, { label: 'MIDDLE', run: function () { align('middle'); } }, { label: 'BOTTOM', run: function () { align('bottom'); } }, ] }, '-', { label: single ? 'Delete' : 'Delete ' + count + ' nodes', run: deleteSelection, danger: true }, ]); } function edgeMenu(e) { if (readOnly) return []; return [ { label: 'Edit…', run: function () { editEdge(e.id); }, title: 'Label, the sides it meets, and the shape of its ends' }, '-', { colors: e.color || '', run: function (c) { paint(new Set(), new Set([e.id]), c); } }, { label: 'Reverse direction', run: function () { reverseEdge(e); } }, '-', { label: 'Delete edge', run: deleteSelection, danger: true }, ]; } // The browser's own menu never shows over the canvas or ours. Where it // comes on mouseup (Windows), its event lands on our menu, which was // just opened under the pointer, rather than on the viewport. Fields in // the edit window keep it, for spelling and paste. document.addEventListener('contextmenu', function (e) { if (viewport.contains(e.target) || (menu && menu.contains(e.target))) e.preventDefault(); }); // === Pointer input === var gesture = null; var pointers = new Map(); var spaceDown = false; var suppressClick = false; var pressTimer = null; var lastPointer = null; // world point of the mouse, where pastes land function cancelPress() { clearTimeout(pressTimer); pressTimer = null; } function startPinch() { var pts = Array.from(pointers.values()); var a = viewportPoint(pts[0].x, pts[0].y), b = viewportPoint(pts[1].x, pts[1].y); var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; gesture = { type: 'pinch', dist: Math.hypot(a.x - b.x, a.y - b.y) || 1, world: { x: (mid.x - cam.x) / cam.z, y: (mid.y - cam.y) / cam.z }, z: cam.z, }; } viewport.addEventListener('pointerdown', function (e) { pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); cancelPress(); if (pointers.size === 2) { if (gesture && gesture.before) restore(gesture.before); startPinch(); return; } if (pointers.size > 2) return; settle(); viewport.focus({ preventScroll: true }); var base = { startX: e.clientX, startY: e.clientY, moved: false }; var nodeEl = e.target.closest('.canvas-node'); var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label'); var additive = e.shiftKey || e.ctrlKey || e.metaKey; var touch = e.pointerType !== 'mouse'; // Middle and right drag pan; a right click that does not move opens // the menu (endPointer). if (e.button === 1 || e.button === 2 || (e.button === 0 && spaceDown)) { e.preventDefault(); gesture = Object.assign(base, { type: 'pan', menu: e.button === 2, target: e.target, cam: Object.assign({}, cam) }); return; } if (e.button !== 0) return; // A long press on touch opens the menu of what is under the finger. if (touch && !e.target.closest('.canvas-handle, .canvas-resize')) { var target = e.target; pressTimer = setTimeout(function () { pressTimer = null; if (!gesture || gesture.moved || pointers.size !== 1) return; gesture = null; suppressClick = true; contextMenu(e.clientX, e.clientY, target); }, LONG_PRESS); } if (nodeEl && !readOnly && e.target.closest('.canvas-handle')) { e.preventDefault(); gesture = Object.assign(base, { type: 'connect', from: nodeEl.dataset.id, side: e.target.dataset.side }); return; } if (nodeEl && !readOnly && e.target.closest('.canvas-resize')) { e.preventDefault(); var rn = byId[nodeEl.dataset.id]; gesture = Object.assign(base, { type: 'resize', id: rn.id, w: rn.width, h: rn.height, before: snapshot() }); return; } if (nodeEl) { var id = nodeEl.dataset.id; var wasSelected = selected.has(id); if (additive) toggleSelected(id); else if (!wasSelected) select([id]); if (readOnly || (additive && wasSelected)) { gesture = Object.assign(base, { type: 'none' }); return; } var ids = movedSet(Array.from(selected)); var origins = {}; ids.forEach(function (i) { origins[i] = { x: byId[i].x, y: byId[i].y }; }); gesture = Object.assign(base, { type: 'move', id: id, ids: ids, origins: origins, additive: additive, before: snapshot() }); return; } if (edgeEl) { var eid = edgeEl.dataset.id; if (additive) { var s = new Set(selectedEdges); if (s.has(eid)) s.delete(eid); else s.add(eid); select(Array.from(selected), Array.from(s)); } else { select([], [eid]); } gesture = Object.assign(base, { type: 'none' }); return; } // The background: a mouse selects with a rubber band, a finger pans. if (touch) { if (!additive) select([]); gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) }); } else { var keep = additive ? Array.from(selected) : []; select(keep, additive ? Array.from(selectedEdges) : []); var box = document.createElement('div'); box.className = 'canvas-marquee'; viewport.appendChild(box); gesture = Object.assign(base, { type: 'marquee', box: box, keep: keep }); } }); window.addEventListener('pointermove', function (e) { if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (e.pointerType === 'mouse' && viewport.contains(e.target)) lastPointer = toWorld(e.clientX, e.clientY); if (!gesture) return; if (gesture.type === 'pinch') { if (pointers.size < 2) return; var pts = Array.from(pointers.values()); var a = viewportPoint(pts[0].x, pts[0].y), b = viewportPoint(pts[1].x, pts[1].y); var z = clamp(gesture.z * Math.hypot(a.x - b.x, a.y - b.y) / gesture.dist, 0.1, 4); cam.z = z; cam.x = (a.x + b.x) / 2 - gesture.world.x * z; cam.y = (a.y + b.y) / 2 - gesture.world.y * z; applyCam(); return; } var dxs = e.clientX - gesture.startX, dys = e.clientY - gesture.startY; if (!gesture.moved) { if (Math.hypot(dxs, dys) < (e.pointerType === 'mouse' ? 4 : 8)) return; gesture.moved = true; cancelPress(); if (gesture.type === 'pan') viewport.classList.add('is-panning'); } var dx = dxs / cam.z, dy = dys / cam.z; switch (gesture.type) { case 'pan': cam.x = gesture.cam.x + dxs; cam.y = gesture.cam.y + dys; applyCam(); break; case 'move': gesture.ids.forEach(function (id) { var n = byId[id], o = gesture.origins[id]; if (!n) return; n.x = e.altKey ? Math.round(o.x + dx) : snap(o.x + dx); n.y = e.altKey ? Math.round(o.y + dy) : snap(o.y + dy); place(els[id], n); }); renderEdges(); break; case 'resize': var n = byId[gesture.id]; n.width = Math.max(MIN_W, e.altKey ? Math.round(gesture.w + dx) : snap(gesture.w + dx)); n.height = Math.max(MIN_H, e.altKey ? Math.round(gesture.h + dy) : snap(gesture.h + dy)); place(els[n.id], n); renderEdges(); break; case 'connect': draft = { from: gesture.from, side: gesture.side, to: toWorld(e.clientX, e.clientY) }; renderEdges(); break; case 'marquee': var p0 = viewportPoint(gesture.startX, gesture.startY), p1 = viewportPoint(e.clientX, e.clientY); var r = { x1: Math.min(p0.x, p1.x), y1: Math.min(p0.y, p1.y), x2: Math.max(p0.x, p1.x), y2: Math.max(p0.y, p1.y) }; Object.assign(gesture.box.style, { left: r.x1 + 'px', top: r.y1 + 'px', width: (r.x2 - r.x1) + 'px', height: (r.y2 - r.y1) + 'px' }); var w1 = { x: (r.x1 - cam.x) / cam.z, y: (r.y1 - cam.y) / cam.z }; var w2 = { x: (r.x2 - cam.x) / cam.z, y: (r.y2 - cam.y) / cam.z }; var hit = doc.nodes.filter(function (n) { return n.x < w2.x && n.x + n.width > w1.x && n.y < w2.y && n.y + n.height > w1.y; }).map(function (n) { return n.id; }); select(gesture.keep.concat(hit)); break; } }); function endPointer(e) { pointers.delete(e.pointerId); cancelPress(); var g = gesture; if (!g) return; if (g.type === 'pinch') { if (pointers.size < 2) gesture = null; return; } gesture = null; viewport.classList.remove('is-panning'); if (g.moved) suppressClick = true; switch (g.type) { case 'pan': if (g.menu && !g.moved && e.type === 'pointerup') contextMenu(e.clientX, e.clientY, g.target); break; case 'move': case 'resize': if (g.moved) { pushUndo(g.before); scheduleSave(); } else if (g.type === 'move' && !g.additive && e.type === 'pointerup') { select([g.id]); } break; case 'connect': draft = null; if (!g.moved || e.type !== 'pointerup') { renderEdges(); break; } var target = document.elementFromPoint(e.clientX, e.clientY); var toEl = target && target.closest('.canvas-node'); var p = toWorld(e.clientX, e.clientY); var edge = { id: newId(), fromNode: g.from, fromSide: g.side }; if (toEl && toEl.dataset.id !== g.from && byId[toEl.dataset.id]) { var to = byId[toEl.dataset.id]; edge.toNode = to.id; edge.toSide = target.classList.contains('canvas-handle') ? target.dataset.side : sideToward(to, p); mutate(function () { doc.edges.push(edge); }); select([], [edge.id]); } else if (!toEl && viewport.contains(target)) { // Dropped on empty space: a new note there, connected. closeWindow(); var before = snapshot(); var n = { id: newId(), type: 'text', text: '', width: 260, height: 80 }; // Its side facing back sits at the drop point. var side = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[g.side]; var dir = NORMALS[side]; n.x = snap(p.x - n.width / 2 - dir[0] * n.width / 2); n.y = snap(p.y - n.height / 2 - dir[1] * n.height / 2); edge.toNode = n.id; edge.toSide = side; doc.nodes.push(n); doc.edges.push(edge); syncDOM(); select([n.id]); scheduleSave(); editNode(n.id, true, before); } else { renderEdges(); } break; case 'marquee': g.box.remove(); break; } } window.addEventListener('pointerup', endPointer); window.addEventListener('pointercancel', endPointer); // A drag that started on a link or checkbox must not also click it. window.addEventListener('click', function (e) { if (!suppressClick) return; suppressClick = false; if (viewport.contains(e.target)) { e.preventDefault(); e.stopPropagation(); } }, true); viewport.addEventListener('pointerdown', function () { suppressClick = false; }, true); // Native drag of images and links would swallow the pointer moves. viewport.addEventListener('dragstart', function (e) { e.preventDefault(); }); // The wheel zooms around the pointer, anywhere: cards crop what they // cannot show rather than scroll. viewport.addEventListener('wheel', function (e) { e.preventDefault(); closeMenu(); var dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1); if (e.shiftKey) { cam.x -= dy; applyCam(); return; } var p = viewportPoint(e.clientX, e.clientY); zoomAt(p.x, p.y, cam.z * Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0015))); }, { passive: false }); // A double click of the wheel button is back to 100%. viewport.addEventListener('auxclick', function (e) { if (e.button !== 1 || e.detail !== 2) return; var p = viewportPoint(e.clientX, e.clientY); zoomAt(p.x, p.y, 1); }); viewport.addEventListener('dblclick', function (e) { if (readOnly) return; var nodeEl = e.target.closest('.canvas-node'); var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label'); if (nodeEl) { if (e.target.closest('a, input, video, audio')) return; e.preventDefault(); editNode(nodeEl.dataset.id); } else if (edgeEl) { editEdge(edgeEl.dataset.id); } else { addText(toWorld(e.clientX, e.clientY)); } }); // Task checkboxes in notes flip their line in the note's text. viewport.addEventListener('change', function (e) { var box = e.target; if (!box.matches || !box.matches('input.task-checkbox[data-task]')) return; var nodeEl = box.closest('.canvas-node'); var n = nodeEl && byId[nodeEl.dataset.id]; var text = n && flipTask(n.text || '', +box.dataset.task, box.checked); if (text === null || text === undefined || readOnly) { box.checked = !box.checked; return; } mutate(function () { n.text = text; }); }); // flipTask mirrors tasks.go flipTaskLine: the Nth task line outside // fenced code gets its box set. function flipTask(text, idx, checked) { var lines = text.split('\n'); var inFence = false, count = 0; for (var i = 0; i < lines.length; i++) { var t = lines[i].replace(/^[ \t]+/, ''); if (t.indexOf('```') === 0 || t.indexOf('~~~') === 0) { inFence = !inFence; continue; } if (inFence) continue; var m = /^(\s*[-*+]\s+)\[([ xX])\]/.exec(lines[i]); if (!m) continue; if (count === idx) { lines[i] = m[1] + (checked ? '[x]' : '[ ]') + lines[i].slice(m[0].length); return lines.join('\n'); } count++; } return null; } // === Keyboard, clipboard === function typingInField(e) { var t = e.target; return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable); } // ignored is true for keys and clipboard events meant for something else: // a field, an edit window, a menu or a modal. function ignored(e) { return modalOpen() || typingInField(e) || inWindow(e); } // Capture phase, so the page shortcuts (global-shortcuts.js) don't see // the ones that mean something else here: E edits the selected node, M // (move page) does not apply to a canvas. window.addEventListener('keydown', function (e) { if (!e.altKey || !e.shiftKey || modalOpen() || inWindow(e)) return; if (e.key === 'E') { e.preventDefault(); e.stopPropagation(); editSelected(); } else if (e.key === 'M') { e.stopPropagation(); } }, true); function editSelected() { if (selected.size === 1 && !selectedEdges.size) editNode(Array.from(selected)[0]); else if (selectedEdges.size === 1 && !selected.size) editEdge(Array.from(selectedEdges)[0]); } document.addEventListener('keydown', function (e) { if (e.key === ' ' && !typingInField(e)) spaceDown = true; if (e.key === 'Escape' && menu) { closeMenu(); viewport.focus({ preventScroll: true }); return; } if (ignored(e)) return; var mod = e.ctrlKey || e.metaKey; var key = e.key.toLowerCase(); var step = e.shiftKey ? 10 : 1; // Looking around works on a read-only canvas too. switch (true) { case e.key === 'Escape': if (menu) closeMenu(); else if (win) closeWindow(); else select([]); return; case mod && key === '0': e.preventDefault(); fit(); return; case mod && key === '1': e.preventDefault(); zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); return; case mod && (key === '=' || key === '+'): e.preventDefault(); zoomCenter(1.25); return; case mod && key === '-': e.preventDefault(); zoomCenter(0.8); return; case !mod && !e.altKey && key === 'f': e.preventDefault(); zoomToSelection(); return; case mod && key === 'a': e.preventDefault(); select(doc.nodes.map(function (n) { return n.id; })); return; } if (readOnly) return; switch (true) { case e.key === 'Delete' || e.key === 'Backspace': e.preventDefault(); deleteSelection(); break; case mod && !e.shiftKey && key === 'z': e.preventDefault(); undo(); break; case mod && (key === 'y' || (e.shiftKey && key === 'z')): e.preventDefault(); redo(); break; case mod && key === 'd': e.preventDefault(); duplicateSelection(); break; case mod && key === 'g': e.preventDefault(); if (selected.size) addGroup(null, true); break; case e.key === 'F2' || (e.key === 'Enter' && !mod): e.preventDefault(); editSelected(); break; case e.key === 'ArrowLeft': e.preventDefault(); nudge(-step, 0); break; case e.key === 'ArrowRight': e.preventDefault(); nudge(step, 0); break; case e.key === 'ArrowUp': e.preventDefault(); nudge(0, -step); break; case e.key === 'ArrowDown': e.preventDefault(); nudge(0, step); break; } }); document.addEventListener('keyup', function (e) { if (e.key === ' ') spaceDown = false; }); // A copy goes out as JSON Canvas, so it pastes back as nodes, here or in // another canvas. function copy(e) { if (ignored(e) || !selected.size) return false; e.clipboardData.setData('text/plain', JSON.stringify(clip(), null, '\t')); e.preventDefault(); return true; } document.addEventListener('copy', copy); document.addEventListener('cut', function (e) { if (!readOnly && copy(e)) deleteSelection(); }); document.addEventListener('paste', function (e) { if (readOnly || ignored(e)) return; var text = e.clipboardData.getData('text/plain'); if (!text) return; e.preventDefault(); paste(text, lastPointer || viewCenter()); }); // === Start === if (window.MarkdownEditor && editorToolbar) { MarkdownEditor.bind(editorToolbar, function () { return win && win.editor && win.editor.actions; }); } reindex(); nodesEl.querySelectorAll('.canvas-node').forEach(function (el) { var n = byId[el.dataset.id]; if (!n) return; els[n.id] = el; elKey[n.id] = contentKey(n); htmlCache[elKey[n.id]] = el.outerHTML; decorate(el); }); renderEdges(); var saved = null; try { saved = JSON.parse(sessionStorage.getItem(camKey)); } catch (e) {} if (saved && isFinite(saved.x) && isFinite(saved.y) && saved.z > 0) { cam = saved; applyCam(); } else { fit(); } if (!readOnly) setStatus('saved'); viewport.focus({ preventScroll: true }); })();