diff --git a/README.md b/README.md index 75eeb02..803e022 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Minimal self-hosted personal wiki. Folders are pages. - **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `![caption](photo.jpg "left")` — relative to the page they sit on. +- **Canvases** an infinite pan/zoom board stored as a [JSON Canvas](https://jsoncanvas.org/) `.canvas` file next to the page's `index.md`. Canvases in a folder appear as tabs above the page content, so a canvas is an alternative *view* of the page rather than a separate document. See the [Canvases](#canvases) section. + - **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata. - **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details. @@ -106,6 +108,77 @@ They resolve against the year page rather than separate per-day URLs: Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page. +## Canvases + +A canvas is a `.canvas` file in a page folder holding a +[JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) document. Every canvas in a +folder renders as a tab above that page's content, and the page itself is the +first tab — so a page and its diagrams are one thing with several views. + +| URL | What it does | +|------|-------------| +| `/?canvas=` | the canvas view (canonical) | +| `/.canvas` | redirects to the canonical form | +| `/.canvas?raw` | the file itself | + +Create one from the **NEW CANVAS** action or the `+` in the tab strip. + +### Nodes + +All four spec node types render: + +| Type | Rendered as | +|------|-------------| +| `text` | markdown, rendered server-side — wikilinks, `![[embeds]]`, tables and task lists all behave as they do on a page | +| `file` | a transclusion of another wiki path: markdown pages (narrowed to a heading when `subpath` is set), images, video, PDFs | +| `link` | a card for an external URL | +| `group` | a labelled frame; dragging it carries the nodes inside it | + +Node content is rendered by the server and cached in the browser by *content*, +so the whole canvas arrives pre-rendered in the initial page load (no per-node +requests) and undo/redo of a text edit costs nothing. + +### Controls + +| Gesture | Action | +|---------|--------| +| drag background | pan | +| ctrl/⌘ + wheel, or pinch | zoom | +| shift + drag background | marquee select | +| drag a node | move it (snaps to a 20px grid; hold alt to bypass) | +| drag a corner handle | resize | +| drag a side port | draw an edge to another node | +| double-click a node | edit it | +| double-click empty space | new text node there | + +Keys: `Ctrl+S` save · `Ctrl+Z` / `Ctrl+Shift+Z` undo/redo · `Del` delete +selection · `Ctrl+A` select all · arrows nudge (shift for single pixels) · +`T`/`F`/`L`/`G` add a node · `+`/`-`/`0` zoom, `9` fit · `Esc` cancel. + +Undo covers the whole session; a drag or resize is one entry no matter how many +events it spanned. + +### Saving + +Saving is explicit (**SAVE**, or `Ctrl+S`) and uploads the whole document. The +server parses and validates it before writing anything, so a malformed payload +can never land on disk, and four things are checked in order: + +1. the JSON parses into the spec's shape; +2. node ids are unique, every edge endpoint resolves, and enums/colours/sizes are in range; +3. the `X-Canvas-Version` token matches the file on disk — a mismatch is a `409` and you are asked whether to reload or overwrite; +4. a save that would empty a populated canvas is refused until you confirm it. + +The previous contents are kept beside the file as `..canvas.bak`, and the +write itself is atomic. Leaving the page with unsaved changes prompts first. + +`[[wikilinks]]` inside text nodes are search queries, like on a page, so they +keep resolving after a move. File nodes and group backgrounds hold literal +paths and are not rewritten: moving or renaming their target leaves them +rendered as broken references. + +Canvas *contents* are not searchable; only the file name is indexed. + ## Quick-Add Bookmarklet Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target): diff --git a/assets/canvas/doc.js b/assets/canvas/doc.js new file mode 100644 index 0000000..0ea65be --- /dev/null +++ b/assets/canvas/doc.js @@ -0,0 +1,182 @@ +// doc.js — the canvas document plus its undo stack. +// +// Every mutation passes through begin()/commit(). A gesture (a drag, a resize, +// a nudge-repeat) spans hundreds of pointer events but has to collapse into a +// single undo entry, so the snapshot is taken once at begin() and pushed once +// at commit() — and only when the document actually changed, so a click that +// moves nothing leaves no trace. +// +// Undo is snapshot-based rather than a stack of inverse operations: the +// document is a small pure-JSON object, cloning it costs microseconds, and a +// snapshot cannot be subtly wrong the way a hand-written inverse can. The +// stack is capped so a long session cannot grow without bound. +window.Canvas = window.Canvas || {}; + +Canvas.Doc = (function () { + var MAX_UNDO = 100; + + var doc = { nodes: [], edges: [] }; + var undoStack = []; + var redoStack = []; + var pending = null; + var dirty = false; + var version = ''; + var listeners = []; + + function clone(v) { return JSON.parse(JSON.stringify(v)); } + + function emit() { + for (var i = 0; i < listeners.length; i++) listeners[i](); + } + + // newId mints a 16-hex-character node/edge id, the shape the format uses in + // the wild. Collisions are checked against the live document anyway. + function newId() { + var bytes = new Uint8Array(8); + window.crypto.getRandomValues(bytes); + var out = ''; + for (var i = 0; i < bytes.length; i++) { + out += (bytes[i] + 0x100).toString(16).slice(1); + } + if (find(doc.nodes, out) || find(doc.edges, out)) return newId(); + return out; + } + + function find(list, id) { + for (var i = 0; i < list.length; i++) { + if (list[i].id === id) return list[i]; + } + return null; + } + + return { + init: function (d, hash) { + doc = (d && typeof d === 'object') ? d : {}; + if (!Array.isArray(doc.nodes)) doc.nodes = []; + if (!Array.isArray(doc.edges)) doc.edges = []; + undoStack = []; + redoStack = []; + pending = null; + dirty = false; + version = hash || ''; + }, + + get: function () { return doc; }, + nodes: function () { return doc.nodes; }, + edges: function () { return doc.edges; }, + node: function (id) { return find(doc.nodes, id); }, + edge: function (id) { return find(doc.edges, id); }, + newId: newId, + + // edgesFor returns every edge touching any of the given node ids. + edgesFor: function (nodeIds) { + var want = {}; + nodeIds.forEach(function (id) { want[id] = true; }); + return doc.edges.filter(function (e) { + return want[e.fromNode] || want[e.toNode]; + }); + }, + + // --- gesture bracketing --- + // + // Nested begin() calls join the outer gesture rather than starting a + // new one, so a composite action (drag a group, which drags its + // members) still produces exactly one undo entry. + begin: function (label) { + if (pending) { pending.depth++; return; } + pending = { label: label || '', before: clone(doc), depth: 0 }; + }, + + commit: function () { + if (!pending) return; + if (pending.depth > 0) { pending.depth--; return; } + var before = pending.before; + var label = pending.label; + pending = null; + // A gesture that changed nothing (a click that selected without + // moving) must not push an undo entry the user would have to press + // Ctrl+Z through. + if (JSON.stringify(before) === JSON.stringify(doc)) return; + undoStack.push({ label: label, doc: before }); + if (undoStack.length > MAX_UNDO) undoStack.shift(); + redoStack.length = 0; + dirty = true; + emit(); + }, + + // abort rolls the document back to the gesture's starting state, for + // Escape mid-drag. + abort: function () { + if (!pending) return; + if (pending.depth > 0) { pending.depth--; return; } + doc = pending.before; + pending = null; + emit(); + }, + + inGesture: function () { return pending !== null; }, + + undo: function () { + if (!undoStack.length) return false; + var entry = undoStack.pop(); + redoStack.push({ label: entry.label, doc: clone(doc) }); + doc = entry.doc; + dirty = true; + emit(); + return true; + }, + + redo: function () { + if (!redoStack.length) return false; + var entry = redoStack.pop(); + undoStack.push({ label: entry.label, doc: clone(doc) }); + doc = entry.doc; + dirty = true; + emit(); + return true; + }, + + canUndo: function () { return undoStack.length > 0; }, + canRedo: function () { return redoStack.length > 0; }, + + // --- structural helpers --- + + addNode: function (node) { + doc.nodes.push(node); + return node; + }, + + addEdge: function (edge) { + doc.edges.push(edge); + return edge; + }, + + // remove drops the named nodes and edges. Every edge touching a removed + // node goes too — a dangling endpoint would fail validation on save, so + // it can never be allowed to exist even transiently. + remove: function (nodeIds, edgeIds) { + var deadNodes = {}; + (nodeIds || []).forEach(function (id) { deadNodes[id] = true; }); + var deadEdges = {}; + (edgeIds || []).forEach(function (id) { deadEdges[id] = true; }); + + doc.nodes = doc.nodes.filter(function (n) { return !deadNodes[n.id]; }); + doc.edges = doc.edges.filter(function (e) { + return !deadEdges[e.id] && !deadNodes[e.fromNode] && !deadNodes[e.toNode]; + }); + }, + + // --- save state --- + + isDirty: function () { return dirty; }, + markSaved: function (hash) { + dirty = false; + if (hash) version = hash; + emit(); + }, + version: function () { return version; }, + + onChange: function (fn) { listeners.push(fn); }, + notify: emit + }; +})(); diff --git a/assets/canvas/edges.js b/assets/canvas/edges.js new file mode 100644 index 0000000..d100e24 --- /dev/null +++ b/assets/canvas/edges.js @@ -0,0 +1,237 @@ +// edges.js — the SVG edge layer. +// +// Edges are cubic beziers anchored at the midpoint of a node side, with control +// points pushed out along that side's normal. When a side is unspecified the +// dominant axis between the two centres picks one, which keeps auto-routed +// edges predictable instead of hunting for a global minimum. +// +// The SVG sits inside the transformed world and draws in world coordinates, so +// it pans and zooms with the nodes for free. +window.Canvas = window.Canvas || {}; + +Canvas.Edges = (function () { + var Doc = Canvas.Doc; + var Select = Canvas.Select; + + var SVGNS = 'http://www.w3.org/2000/svg'; + var ARROW = 11; + + var svg = null; + var groups = {}; // edge id -> + var temp = null; // the rubber-band path while drawing a new edge + + function el(name, attrs) { + var node = document.createElementNS(SVGNS, name); + for (var k in attrs) { + if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]); + } + return node; + } + + // sideAnchor returns the connection point on a node side together with that + // side's outward normal, which the control points and arrowheads both need. + function sideAnchor(n, side) { + switch (side) { + case 'top': return { x: n.x + n.width / 2, y: n.y, nx: 0, ny: -1 }; + case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height, nx: 0, ny: 1 }; + case 'left': return { x: n.x, y: n.y + n.height / 2, nx: -1, ny: 0 }; + default: return { x: n.x + n.width, y: n.y + n.height / 2, nx: 1, ny: 0 }; + } + } + + // autoSides picks the facing sides for an edge with no explicit ones: the + // dominant axis between the node centres wins. Simple and stable — the edge + // does not flip sides every time a node moves a few pixels. + function autoSides(a, b) { + var dx = (b.x + b.width / 2) - (a.x + a.width / 2); + var dy = (b.y + b.height / 2) - (a.y + a.height / 2); + if (Math.abs(dx) >= Math.abs(dy)) { + return dx >= 0 ? ['right', 'left'] : ['left', 'right']; + } + return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom']; + } + + function geometry(e) { + var a = Doc.node(e.fromNode); + var b = Doc.node(e.toNode); + if (!a || !b) return null; + + var auto = autoSides(a, b); + var fromSide = e.fromSide || auto[0]; + // A self-loop has no meaningful "facing" side, so it gets a fixed pair + // that always produces a visible arc out of and back into the node. + var toSide = e.toSide || (a === b ? 'top' : auto[1]); + + var p0 = sideAnchor(a, fromSide); + var p3 = sideAnchor(b, toSide); + var dist = Math.sqrt((p3.x - p0.x) * (p3.x - p0.x) + (p3.y - p0.y) * (p3.y - p0.y)); + var k = Math.max(40, dist / 3); + return { + p0: p0, + p3: p3, + c1: { x: p0.x + p0.nx * k, y: p0.y + p0.ny * k }, + c2: { x: p3.x + p3.nx * k, y: p3.y + p3.ny * k } + }; + } + + function curve(g) { + return 'M' + g.p0.x + ',' + g.p0.y + + 'C' + g.c1.x + ',' + g.c1.y + ' ' + g.c2.x + ',' + g.c2.y + + ' ' + g.p3.x + ',' + g.p3.y; + } + + // midpoint of a cubic at t=0.5, closed form — no DOM measurement needed to + // place a label. + function midpoint(g) { + return { + x: (g.p0.x + 3 * g.c1.x + 3 * g.c2.x + g.p3.x) / 8, + y: (g.p0.y + 3 * g.c1.y + 3 * g.c2.y + g.p3.y) / 8 + }; + } + + // arrowhead draws a triangle with its tip at pt, its base offset along the + // outward normal — so the arrow points into the node it terminates on. + function arrowhead(pt) { + var bx = pt.x + pt.nx * ARROW; + var by = pt.y + pt.ny * ARROW; + var px = -pt.ny * ARROW * 0.42; + var py = pt.nx * ARROW * 0.42; + return 'M' + pt.x + ',' + pt.y + + 'L' + (bx + px) + ',' + (by + py) + + 'L' + (bx - px) + ',' + (by - py) + 'Z'; + } + + function colorVar(c) { + if (!c) return 'var(--canvas-edge)'; + if (c.charAt(0) === '#') return c; + return 'var(--canvas-' + c + ')'; + } + + function build(e) { + var g = el('g', { 'class': 'canvas-edge' }); + g.dataset.id = e.id; + // A transparent fat stroke under the visible line is the hit target: + // a 2px curve is essentially unclickable otherwise. + g.appendChild(el('path', { 'class': 'canvas-edge-hit', fill: 'none' })); + g.appendChild(el('path', { 'class': 'canvas-edge-line', fill: 'none' })); + g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-to' })); + g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-from' })); + var label = el('text', { 'class': 'canvas-edge-label', 'text-anchor': 'middle', 'dominant-baseline': 'middle' }); + g.appendChild(label); + return g; + } + + function update(g, e) { + var geo = geometry(e); + if (!geo) { g.setAttribute('display', 'none'); return; } + g.removeAttribute('display'); + + var d = curve(geo); + var stroke = colorVar(e.color); + g.querySelector('.canvas-edge-hit').setAttribute('d', d); + + var line = g.querySelector('.canvas-edge-line'); + line.setAttribute('d', d); + line.setAttribute('stroke', stroke); + + // Spec defaults: fromEnd is "none", toEnd is "arrow". + var toHead = g.querySelector('.canvas-edge-head-to'); + if ((e.toEnd || 'arrow') === 'arrow') { + toHead.setAttribute('d', arrowhead(geo.p3)); + toHead.setAttribute('fill', stroke); + toHead.removeAttribute('display'); + } else { + toHead.setAttribute('display', 'none'); + } + + var fromHead = g.querySelector('.canvas-edge-head-from'); + if ((e.fromEnd || 'none') === 'arrow') { + fromHead.setAttribute('d', arrowhead(geo.p0)); + fromHead.setAttribute('fill', stroke); + fromHead.removeAttribute('display'); + } else { + fromHead.setAttribute('display', 'none'); + } + + var label = g.querySelector('.canvas-edge-label'); + if (e.label) { + var m = midpoint(geo); + label.setAttribute('x', m.x); + label.setAttribute('y', m.y); + label.textContent = e.label; + label.removeAttribute('display'); + } else { + label.setAttribute('display', 'none'); + } + + g.classList.toggle('is-selected', Select.hasEdge(e.id)); + } + + return { + init: function (svgEl) { svg = svgEl; }, + + sideAnchor: sideAnchor, + autoSides: autoSides, + + render: function () { + var seen = {}; + Doc.edges().forEach(function (e) { + seen[e.id] = true; + var g = groups[e.id]; + if (!g) { + g = build(e); + groups[e.id] = g; + svg.appendChild(g); + } + update(g, e); + }); + Object.keys(groups).forEach(function (id) { + if (seen[id]) return; + groups[id].remove(); + delete groups[id]; + }); + }, + + // syncFor repaints only the edges touching the given nodes. Called on + // every frame of a drag, where re-running the full reconcile would be + // pure overhead. + syncFor: function (nodeIds) { + Doc.edgesFor(nodeIds).forEach(function (e) { + var g = groups[e.id]; + if (g) update(g, e); + }); + }, + + syncSelection: function () { + Doc.edges().forEach(function (e) { + var g = groups[e.id]; + if (g) g.classList.toggle('is-selected', Select.hasEdge(e.id)); + }); + }, + + // --- rubber band shown while dragging a new edge out of a port --- + + beginTemp: function () { + if (temp) temp.remove(); + temp = el('path', { 'class': 'canvas-edge-temp', fill: 'none' }); + svg.appendChild(temp); + }, + + updateTemp: function (node, side, to) { + if (!temp) return; + var p0 = sideAnchor(node, side); + var dist = Math.sqrt((to.x - p0.x) * (to.x - p0.x) + (to.y - p0.y) * (to.y - p0.y)); + var k = Math.max(40, dist / 3); + temp.setAttribute('d', + 'M' + p0.x + ',' + p0.y + + 'C' + (p0.x + p0.nx * k) + ',' + (p0.y + p0.ny * k) + + ' ' + to.x + ',' + to.y + ' ' + to.x + ',' + to.y); + }, + + endTemp: function () { + if (!temp) return; + temp.remove(); + temp = null; + } + }; +})(); diff --git a/assets/canvas/edit.js b/assets/canvas/edit.js new file mode 100644 index 0000000..0205723 --- /dev/null +++ b/assets/canvas/edit.js @@ -0,0 +1,204 @@ +// edit.js — editing a node's content. +// +// Text nodes get the vendored CodeMirror editor in place, positioned over the +// node in *screen* coordinates rather than mounted inside the zoomed world. +// CodeMirror measures its own layout in device pixels, so a CSS-scaled parent +// wrecks caret placement and selection maths; a fixed-position panel sidesteps +// that entirely and stays sharp at every zoom level. +// +// The other node types have nothing to type into, so they reuse the existing +// tree picker and modal instead of an editor. +window.Canvas = window.Canvas || {}; + +Canvas.Edit = (function () { + var Doc = Canvas.Doc; + var View = Canvas.View; + + var MIN_WIDTH = 360; + var MIN_HEIGHT = 200; + + var panel = null; + var view = null; + var currentId = null; + var original = ''; + + function destroy() { + if (view) { view.destroy(); view = null; } + if (panel) { panel.remove(); panel = null; } + currentId = null; + original = ''; + document.removeEventListener('pointerdown', onOutside, true); + } + + function commit() { + if (!panel || !view) return; + var text = view.state.doc.toString(); + var id = currentId; + destroy(); + var n = Doc.node(id); + if (!n || text === original) return; + Doc.begin('edit text'); + n.text = text; + Doc.commit(); + } + + function onOutside(e) { + if (!panel || panel.contains(e.target)) return; + commit(); + } + + // screenRect places the editor over the node it edits, clamped to a usable + // minimum and kept inside the viewport — a 60x60 node still needs a panel + // big enough to type in. + function screenRect(n) { + var s = View.scale(); + var tl = View.toScreen(n.x, n.y); + var w = Math.max(MIN_WIDTH, n.width * s); + var h = Math.max(MIN_HEIGHT, n.height * s); + w = Math.min(w, window.innerWidth - 16); + h = Math.min(h, window.innerHeight - 16); + var left = Math.max(8, Math.min(tl.x, window.innerWidth - w - 8)); + var top = Math.max(8, Math.min(tl.y, window.innerHeight - h - 8)); + return { left: left, top: top, width: w, height: h }; + } + + function openText(n) { + var rect = screenRect(n); + panel = document.createElement('div'); + panel.className = 'canvas-editor panel panel-floating'; + panel.style.left = rect.left + 'px'; + panel.style.top = rect.top + 'px'; + panel.style.width = rect.width + 'px'; + panel.style.height = rect.height + 'px'; + + var hint = document.createElement('div'); + hint.className = 'panel-header'; + hint.textContent = 'Ctrl+Enter or click away to apply · Escape to cancel'; + panel.appendChild(hint); + + var mount = document.createElement('div'); + mount.className = 'canvas-editor-mount'; + panel.appendChild(mount); + document.body.appendChild(panel); + + currentId = n.id; + original = n.text || ''; + + view = new CM.EditorView({ + state: CM.EditorState.create({ + doc: original, + selection: { anchor: original.length }, + extensions: [ + CM.history(), + CM.drawSelection(), + CM.indentOnInput(), + CM.EditorView.lineWrapping, + CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }), + CM.markdown({ base: CM.markdownLanguage }), + CM.syntaxHighlighting(CM.highlightStyle), + CM.closeBrackets(), + CM.autocompletion({ override: [WikiComplete.source] }), + CM.theme, + // Mod-Enter goes first (nothing else claims it); Escape goes + // last so an open completion popup still gets first refusal + // on it and closes itself instead of discarding the edit. + CM.keymap.of([{ key: 'Mod-Enter', run: function () { commit(); return true; } }] + .concat( + CM.closeBracketsKeymap, + CM.completionKeymap, + CM.markdownKeymap, + CM.defaultKeymap, + CM.historyKeymap, + [CM.indentWithTab], + [{ key: 'Escape', run: function () { destroy(); return true; } }] + )) + ] + }), + parent: mount + }); + view.focus(); + // Capture phase: a click on a toolbar button should apply the edit + // before that button's own handler reads the document. + document.addEventListener('pointerdown', onOutside, true); + } + + function openFile(n) { + var start = (n.file || '/').replace(/\/+$/, '') || '/'; + openTreePicker({ + title: 'File node — pick a target', + mode: 'any', + initialPath: start, + preselect: start, + confirmLabel: 'USE', + onSelect: function (p) { + if (!p || p === n.file) return; + Doc.begin('retarget file node'); + n.file = p; + n.subpath = ''; + Doc.commit(); + } + }); + } + + function promptValue(title, label, value, onValue) { + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'input'; + input.placeholder = label; + input.value = value || ''; + openModal({ + title: title, + body: input, + confirm: { + label: 'OK', + onConfirm: function () { + closeModal(); + onValue(input.value.trim()); + } + } + }); + } + + function openLink(n) { + promptValue('Link node', 'https://example.com', n.url, function (v) { + if (!v || v === n.url) return; + Doc.begin('edit link'); + n.url = v; + Doc.commit(); + }); + } + + function openGroup(n) { + promptValue('Group label', 'Label', n.label, function (v) { + if (v === (n.label || '')) return; + Doc.begin('edit group label'); + if (v) n.label = v; else delete n.label; + Doc.commit(); + }); + } + + // close applies whatever is currently open, if anything. + function close() { + if (panel) commit(); + } + + return { + open: function (id) { + var n = Doc.node(id); + if (!n) return; + close(); + switch (n.type) { + case 'text': openText(n); break; + case 'file': openFile(n); break; + case 'link': openLink(n); break; + case 'group': openGroup(n); break; + } + }, + + // Called from the gesture layer on any pointerdown on the surface, so + // starting a drag never silently drops an edit. + close: close, + + isOpen: function () { return panel !== null; } + }; +})(); diff --git a/assets/canvas/interact.js b/assets/canvas/interact.js new file mode 100644 index 0000000..88d103a --- /dev/null +++ b/assets/canvas/interact.js @@ -0,0 +1,461 @@ +// interact.js — pointer and keyboard gestures. +// +// Every gesture is bracketed by Doc.begin()/Doc.commit(), so a drag that spans +// hundreds of pointermove events collapses into one undo entry. Within a +// gesture the document is mutated directly and only the affected elements are +// re-synced; the full reconcile happens once, on commit. +// +// Gesture routing on pointerdown, in priority order: +// resize handle -> resize port -> draw an edge +// node -> select + drag edge -> select +// background -> pan (shift: marquee select; two fingers: pinch zoom) +window.Canvas = window.Canvas || {}; + +Canvas.Interact = (function () { + var Doc = Canvas.Doc; + var View = Canvas.View; + var Select = Canvas.Select; + var Nodes = Canvas.Nodes; + var Edges = Canvas.Edges; + + var MIN_SIZE = 40; + var DRAG_SLOP = 3; + + var surface = null; + var marquee = null; + var gesture = null; + var pointers = {}; + + function snap(v) { + return Math.round(v / View.GRID) * View.GRID; + } + + function pointerCount() { + return Object.keys(pointers).length; + } + + // interactive reports whether the pointer landed on something the browser + // should handle itself (a link, a video control), which must not become the + // start of a node drag. + function interactive(target) { + return !!target.closest('a, button, input, select, textarea, video, embed, iframe'); + } + + // groupMembers returns the nodes whose centre sits inside a group, which is + // what "inside" means for dragging: a node straddling the border belongs to + // whichever side holds most of it. + function groupMembers(g) { + return Doc.nodes().filter(function (n) { + if (n.id === g.id) return false; + var cx = n.x + n.width / 2; + var cy = n.y + n.height / 2; + return cx >= g.x && cx <= g.x + g.width && cy >= g.y && cy <= g.y + g.height; + }).map(function (n) { return n.id; }); + } + + // withGroupMembers expands a selection so dragging a group carries whatever + // it contains. + function withGroupMembers(ids) { + var out = {}; + ids.forEach(function (id) { + out[id] = true; + var n = Doc.node(id); + if (n && n.type === 'group') { + groupMembers(n).forEach(function (m) { out[m] = true; }); + } + }); + return Object.keys(out); + } + + function selectOnPress(id, additive) { + if (additive) { + Select.toggleNode(id); + return; + } + if (!Select.hasNode(id)) Select.set([id], []); + } + + // --- gesture starts --- + + function startDrag(id, e) { + selectOnPress(id, e.shiftKey); + if (!Select.hasNode(id)) return; // shift-click deselected it + var ids = withGroupMembers(Select.nodes()); + Doc.begin('move'); + gesture = { + kind: 'drag', + ids: ids, + start: View.toWorld(e.clientX, e.clientY), + origin: ids.map(function (nid) { + var n = Doc.node(nid); + return { id: nid, x: n.x, y: n.y }; + }), + moved: false + }; + } + + function startResize(id, corner, e) { + Select.set([id], []); + var n = Doc.node(id); + Doc.begin('resize'); + gesture = { + kind: 'resize', + id: id, + corner: corner, + start: View.toWorld(e.clientX, e.clientY), + origin: { x: n.x, y: n.y, width: n.width, height: n.height }, + moved: false + }; + } + + function startEdge(id, side) { + Edges.beginTemp(); + gesture = { kind: 'edge', fromId: id, fromSide: side, target: null, moved: false }; + } + + function startPan(e) { + gesture = { kind: 'pan', last: { x: e.clientX, y: e.clientY }, moved: false }; + } + + function startMarquee(e) { + var r = surface.getBoundingClientRect(); + gesture = { + kind: 'marquee', + origin: { x: e.clientX - r.left, y: e.clientY - r.top }, + additive: e.ctrlKey || e.metaKey, + moved: false + }; + marquee.hidden = false; + updateMarquee(e); + } + + function startPinch() { + var ids = Object.keys(pointers); + var a = pointers[ids[0]]; + var b = pointers[ids[1]]; + gesture = { + kind: 'pinch', + dist: Math.hypot(b.x - a.x, b.y - a.y), + mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, + moved: true + }; + } + + // --- gesture updates --- + + function moveDrag(e) { + var p = View.toWorld(e.clientX, e.clientY); + var lead = gesture.origin[0]; + if (!lead) return; + var dx = p.x - gesture.start.x; + var dy = p.y - gesture.start.y; + // Snap the lead node to the grid and shift the rest by the same amount, + // so a multi-node drag keeps its relative layout. Alt opts out. + if (!e.altKey) { + dx = snap(lead.x + dx) - lead.x; + dy = snap(lead.y + dy) - lead.y; + } + gesture.origin.forEach(function (o) { + var n = Doc.node(o.id); + if (!n) return; + n.x = Math.round(o.x + dx); + n.y = Math.round(o.y + dy); + }); + gesture.ids.forEach(Nodes.syncGeom); + Edges.syncFor(gesture.ids); + } + + function moveResize(e) { + var n = Doc.node(gesture.id); + if (!n) return; + var p = View.toWorld(e.clientX, e.clientY); + var o = gesture.origin; + var dx = p.x - gesture.start.x; + var dy = p.y - gesture.start.y; + var right = o.x + o.width; + var bottom = o.y + o.height; + var x = o.x, y = o.y, w = o.width, h = o.height; + + if (gesture.corner.indexOf('w') !== -1) { + x = e.altKey ? o.x + dx : snap(o.x + dx); + w = right - x; + } else { + w = e.altKey ? o.width + dx : snap(right + dx) - o.x; + } + if (gesture.corner.indexOf('n') !== -1) { + y = e.altKey ? o.y + dy : snap(o.y + dy); + h = bottom - y; + } else { + h = e.altKey ? o.height + dy : snap(bottom + dy) - o.y; + } + + // Clamp against the fixed edge so shrinking past the minimum pins the + // node instead of flipping it inside out. + if (w < MIN_SIZE) { + if (gesture.corner.indexOf('w') !== -1) x = right - MIN_SIZE; + w = MIN_SIZE; + } + if (h < MIN_SIZE) { + if (gesture.corner.indexOf('n') !== -1) y = bottom - MIN_SIZE; + h = MIN_SIZE; + } + + n.x = Math.round(x); + n.y = Math.round(y); + n.width = Math.round(w); + n.height = Math.round(h); + Nodes.syncGeom(n.id); + Edges.syncFor([n.id]); + } + + function moveEdge(e) { + var from = Doc.node(gesture.fromId); + if (!from) return; + Edges.updateTemp(from, gesture.fromSide, View.toWorld(e.clientX, e.clientY)); + + var under = document.elementFromPoint(e.clientX, e.clientY); + var nodeEl = under && under.closest ? under.closest('.canvas-node') : null; + var id = nodeEl ? nodeEl.dataset.id : null; + if (id === gesture.target) return; + if (gesture.target) { + var prev = Nodes.el(gesture.target); + if (prev) prev.classList.remove('is-edge-target'); + } + gesture.target = id; + if (id && nodeEl) nodeEl.classList.add('is-edge-target'); + } + + function updateMarquee(e) { + var r = surface.getBoundingClientRect(); + var x = e.clientX - r.left; + var y = e.clientY - r.top; + marquee.style.left = Math.min(x, gesture.origin.x) + 'px'; + marquee.style.top = Math.min(y, gesture.origin.y) + 'px'; + marquee.style.width = Math.abs(x - gesture.origin.x) + 'px'; + marquee.style.height = Math.abs(y - gesture.origin.y) + 'px'; + } + + function movePinch() { + var ids = Object.keys(pointers); + if (ids.length < 2) return; + var a = pointers[ids[0]]; + var b = pointers[ids[1]]; + var dist = Math.hypot(b.x - a.x, b.y - a.y); + var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + if (gesture.dist > 0) View.zoomAt(mid.x, mid.y, dist / gesture.dist); + View.pan(mid.x - gesture.mid.x, mid.y - gesture.mid.y); + gesture.dist = dist; + gesture.mid = mid; + } + + // --- gesture ends --- + + // Both finishers take the gesture as an argument rather than reading the + // module-level one: endGesture clears it before dispatching so a finisher + // cannot accidentally leave a half-torn-down gesture in place. + function finishMarquee(g) { + marquee.hidden = true; + var r = surface.getBoundingClientRect(); + var a = View.toWorld(r.left + parseFloat(marquee.style.left), + r.top + parseFloat(marquee.style.top)); + var b = View.toWorld(r.left + parseFloat(marquee.style.left) + parseFloat(marquee.style.width), + r.top + parseFloat(marquee.style.top) + parseFloat(marquee.style.height)); + var hit = Doc.nodes().filter(function (n) { + return n.x < b.x && n.x + n.width > a.x && n.y < b.y && n.y + n.height > a.y; + }).map(function (n) { return n.id; }); + if (g.additive) { + hit.forEach(Select.addNode); + } else { + Select.set(hit, []); + } + } + + function finishEdge(g) { + if (g.target) { + var el = Nodes.el(g.target); + if (el) el.classList.remove('is-edge-target'); + } + Edges.endTemp(); + if (!g.target) return; + Doc.begin('connect'); + Doc.addEdge({ + id: Doc.newId(), + fromNode: g.fromId, + fromSide: g.fromSide, + toNode: g.target + }); + Doc.commit(); + } + + // --- event wiring --- + + function onDown(e) { + if (Canvas.Edit) Canvas.Edit.close(); + pointers[e.pointerId] = { x: e.clientX, y: e.clientY }; + + if (pointerCount() === 2 && (!gesture || gesture.kind === 'pan')) { + if (gesture) endGesture(true); + startPinch(); + return; + } + if (gesture) return; // one gesture at a time + + var t = e.target; + var handle = t.closest('.canvas-handle'); + var port = t.closest('.canvas-port'); + var nodeEl = t.closest('.canvas-node'); + var edgeEl = t.closest('.canvas-edge'); + + if (handle && nodeEl) { + startResize(nodeEl.dataset.id, handle.dataset.corner, e); + } else if (port && nodeEl) { + startEdge(nodeEl.dataset.id, port.dataset.side); + } else if (nodeEl) { + if (interactive(t)) return; + startDrag(nodeEl.dataset.id, e); + } else if (edgeEl) { + if (e.shiftKey) Select.toggleEdge(edgeEl.dataset.id); + else Select.set([], [edgeEl.dataset.id]); + return; + } else if (e.shiftKey) { + startMarquee(e); + } else { + startPan(e); + } + + if (gesture) { + // Capture keeps the gesture alive when the pointer leaves the + // surface mid-drag. It throws when the pointer is already gone (a + // release that raced the handler), which must not abort the + // gesture — that would leave it stuck with no pointerup to end it. + try { surface.setPointerCapture(e.pointerId); } catch (err) { /* pointer already released */ } + e.preventDefault(); + } + } + + function onMove(e) { + if (pointers[e.pointerId]) { + pointers[e.pointerId].x = e.clientX; + pointers[e.pointerId].y = e.clientY; + } + if (!gesture) return; + + if (gesture.kind === 'pinch') { movePinch(); return; } + + if (!gesture.moved) { + // Ignore sub-pixel jitter so a plain click does not register as a + // drag (and so a click on the background still clears the selection). + var dx = Math.abs(e.movementX || 0); + var dy = Math.abs(e.movementY || 0); + if (dx + dy < DRAG_SLOP && gesture.kind !== 'edge') return; + gesture.moved = true; + } + + switch (gesture.kind) { + case 'drag': moveDrag(e); break; + case 'resize': moveResize(e); break; + case 'edge': moveEdge(e); break; + case 'marquee': updateMarquee(e); break; + case 'pan': + View.pan(e.clientX - gesture.last.x, e.clientY - gesture.last.y); + gesture.last = { x: e.clientX, y: e.clientY }; + break; + } + } + + function endGesture(silent) { + if (!gesture) return; + var g = gesture; + gesture = null; + switch (g.kind) { + case 'drag': + case 'resize': + Doc.commit(); + break; + case 'edge': + if (!silent) finishEdge(g); else Edges.endTemp(); + break; + case 'marquee': + if (!silent) finishMarquee(g); else marquee.hidden = true; + break; + case 'pan': + // A press on the background that never moved is a click, and a + // click on empty space clears the selection. + if (!silent && !g.moved) Select.clear(); + break; + } + } + + function onUp(e) { + delete pointers[e.pointerId]; + if (gesture && gesture.kind === 'pinch' && pointerCount() < 2) { + gesture = null; + return; + } + endGesture(false); + } + + function onWheel(e) { + // Ctrl/meta+wheel is the zoom gesture every drawing tool uses, and is + // also what a trackpad pinch reports. A plain wheel scrolls the canvas. + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + View.zoomAt(e.clientX, e.clientY, Math.pow(0.999, e.deltaY)); + return; + } + e.preventDefault(); + if (e.shiftKey) View.pan(-e.deltaY, 0); + else View.pan(-e.deltaX, -e.deltaY); + } + + function onDblClick(e) { + var nodeEl = e.target.closest('.canvas-node'); + if (nodeEl) { + if (interactive(e.target)) return; + e.preventDefault(); + Canvas.Edit.open(nodeEl.dataset.id); + return; + } + if (e.target.closest('.canvas-edge')) return; + // Double-clicking empty space drops a new text node there and opens it, + // which is how a canvas normally gets its first node. + var p = View.toWorld(e.clientX, e.clientY); + var node = Canvas.Actions.addText(p); + if (node) Canvas.Edit.open(node.id); + } + + return { + init: function (surfaceEl, marqueeEl) { + surface = surfaceEl; + marquee = marqueeEl; + + surface.addEventListener('pointerdown', onDown); + surface.addEventListener('pointermove', onMove); + surface.addEventListener('pointerup', onUp); + surface.addEventListener('pointercancel', onUp); + surface.addEventListener('wheel', onWheel, { passive: false }); + surface.addEventListener('dblclick', onDblClick); + // Native drag would fight the pointer gestures on images and links. + surface.addEventListener('dragstart', function (e) { e.preventDefault(); }); + surface.addEventListener('contextmenu', function (e) { + if (e.target.closest('.canvas-node, .canvas-edge')) e.preventDefault(); + }); + }, + + // cancel aborts an in-flight gesture and rolls the document back, for + // the Escape key. + cancel: function () { + if (!gesture) return false; + var kind = gesture.kind; + if (kind === 'drag' || kind === 'resize') { + gesture = null; + Doc.abort(); + return true; + } + endGesture(true); + return true; + }, + + active: function () { return gesture !== null; } + }; +})(); diff --git a/assets/canvas/main.html b/assets/canvas/main.html new file mode 100644 index 0000000..607fd88 --- /dev/null +++ b/assets/canvas/main.html @@ -0,0 +1,88 @@ +{{define "headerActions"}} +PAGE + + + + + +{{end}} + +{{define "content"}} + +{{if .CanvasError}} +
+

{{.ActiveCanvas}}

+

This canvas could not be opened — {{.CanvasError}}

+

Fix it in the file itself, then reload. + view the raw file · + back to the page

+
+{{else}} +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + +
+
+ + + + + + + + + + + + +{{end}} +{{end}} diff --git a/assets/canvas/main.js b/assets/canvas/main.js new file mode 100644 index 0000000..20ee3aa --- /dev/null +++ b/assets/canvas/main.js @@ -0,0 +1,389 @@ +// main.js — bootstrap, toolbar, and keyboard shortcuts. +// +// The payload the server inlined (document + pre-rendered node HTML + the +// version token) is the only input; nothing else is fetched on load. +(function () { + var data = window.CANVAS_DATA; + var surface = document.getElementById('canvas-surface'); + if (!data || !surface) return; + + var Doc = Canvas.Doc; + var View = Canvas.View; + var Select = Canvas.Select; + var Nodes = Canvas.Nodes; + var Edges = Canvas.Edges; + var Save = Canvas.Save; + + var world = document.getElementById('canvas-world'); + var marquee = document.getElementById('canvas-marquee'); + var hint = document.querySelector('[data-canvas-hint]'); + var statusEl = document.querySelector('[data-canvas-status]'); + var zoomEl = document.querySelector('[data-canvas-zoom]'); + + // Default sizes for new nodes, in world pixels. Multiples of the 20px grid + // so a fresh node lands aligned. + var SIZES = { + text: { width: 260, height: 120 }, + file: { width: 400, height: 400 }, + link: { width: 400, height: 140 }, + group: { width: 520, height: 400 } + }; + + function snap(v) { return Math.round(v / View.GRID) * View.GRID; } + + // --- document actions --- + + Canvas.Actions = { + // add drops a node centred on a world point (the viewport centre when + // called from the toolbar, the cursor when called from a double-click). + add: function (type, extra, at) { + var size = SIZES[type]; + var p = at || View.centreOfViewport(); + var node = { + id: Doc.newId(), + type: type, + x: snap(p.x - size.width / 2), + y: snap(p.y - size.height / 2), + width: size.width, + height: size.height + }; + for (var k in extra) { + if (Object.prototype.hasOwnProperty.call(extra, k)) node[k] = extra[k]; + } + Doc.begin('add ' + type); + Doc.addNode(node); + Doc.commit(); + Select.set([node.id], []); + return node; + }, + + addText: function (at) { + return Canvas.Actions.add('text', { text: '' }, at); + }, + + addGroup: function (at) { + return Canvas.Actions.add('group', { label: 'Group' }, at); + }, + + // A file node is meaningless without a target and a link node without a + // url, so both ask first and only then create — validation on the server + // rejects the empty forms anyway. + addFile: function () { + openTreePicker({ + title: 'File node — pick a target', + mode: 'any', + initialPath: data.postUrl, + confirmLabel: 'ADD', + onSelect: function (p) { + if (p) Canvas.Actions.add('file', { file: p }); + } + }); + }, + + addLink: function () { + var input = document.createElement('input'); + input.type = 'url'; + input.className = 'input'; + input.placeholder = 'https://example.com'; + openModal({ + title: 'Link node', + body: input, + confirm: { + label: 'ADD', + onConfirm: function () { + var v = input.value.trim(); + if (!v) return; + closeModal(); + Canvas.Actions.add('link', { url: v }); + } + } + }); + }, + + deleteSelection: function () { + if (Select.isEmpty()) return; + Doc.begin('delete'); + Doc.remove(Select.nodes(), Select.edges()); + Doc.commit(); + Select.clear(); + }, + + setColor: function (color) { + if (Select.isEmpty()) return; + Doc.begin('colour'); + Select.nodes().forEach(function (id) { + var n = Doc.node(id); + if (!n) return; + if (color) n.color = color; else delete n.color; + }); + Select.edges().forEach(function (id) { + var e = Doc.edge(id); + if (!e) return; + if (color) e.color = color; else delete e.color; + }); + Doc.commit(); + }, + + // nudge moves the selection by whole grid steps, or single pixels with + // shift held for the cases where the grid is in the way. + nudge: function (dx, dy, fine) { + var ids = Select.nodes(); + if (!ids.length) return; + var step = fine ? 1 : View.GRID; + Doc.begin('nudge'); + ids.forEach(function (id) { + var n = Doc.node(id); + if (!n) return; + n.x += dx * step; + n.y += dy * step; + }); + Doc.commit(); + }, + + selectAll: function () { + Select.set( + Doc.nodes().map(function (n) { return n.id; }), + Doc.edges().map(function (e) { return e.id; }) + ); + } + }; + + // --- chrome --- + + function setStatus(text) { + if (statusEl) statusEl.textContent = text || ''; + } + + function updateToolbar() { + var undoBtn = document.querySelector('[data-action="undo"]'); + var redoBtn = document.querySelector('[data-action="redo"]'); + var delBtn = document.querySelector('[data-action="delete-selection"]'); + if (undoBtn) undoBtn.disabled = !Doc.canUndo(); + if (redoBtn) redoBtn.disabled = !Doc.canRedo(); + if (delBtn) delBtn.disabled = Select.isEmpty(); + + var saveBtn = document.querySelector('[data-action="canvas-save"]'); + if (saveBtn) saveBtn.classList.toggle('is-active', Doc.isDirty()); + if (hint) hint.hidden = Doc.nodes().length > 0; + } + + function updateZoom() { + if (zoomEl) zoomEl.textContent = Math.round(View.scale() * 100) + '%'; + } + + // refresh is the single re-render path: prune the selection against the + // (possibly undone) document, reconcile both layers, update the chrome. + function refresh() { + Select.prune(Doc.get()); + Nodes.render(); + Edges.render(); + updateToolbar(); + } + + // --- canvas-level actions (rename / delete the file itself) --- + + function leaveGuard() { + if (!Doc.isDirty()) return true; + return window.confirm('This canvas has unsaved changes. Leave anyway?'); + } + + function canvasAction(url, then) { + fetch(url, { method: 'POST', credentials: 'same-origin', redirect: 'manual' }) + .then(function (res) { + if (res.type === 'opaqueredirect' || res.ok) { then(); return; } + return res.text().then(function (msg) { + alert(msg || ('Request failed (' + res.status + ')')); + }); + }) + .catch(function () { alert('Network error'); }); + } + + function renameCanvas() { + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'input'; + input.value = data.name; + openModal({ + title: 'Rename canvas', + body: input, + confirm: { + label: 'RENAME', + onConfirm: function () { + var v = input.value.trim(); + if (!v || v === data.name) { closeModal(); return; } + closeModal(); + canvasAction( + data.postUrl + '?canvas=' + encodeURIComponent(data.name) + + '&rename=' + encodeURIComponent(v), + function () { + window.location.href = data.postUrl + '?canvas=' + encodeURIComponent(v); + } + ); + } + } + }); + } + + function deleteCanvas() { + openModal({ + title: 'Delete canvas', + body: 'Delete "' + data.name + '"? The page itself is not affected.', + confirm: { + label: 'DELETE', + danger: true, + onConfirm: function () { + closeModal(); + canvasAction( + data.postUrl + '?canvas=' + encodeURIComponent(data.name) + '&delete=1', + function () { window.location.href = data.postUrl; } + ); + } + } + }); + } + + // --- wiring --- + + function wireToolbar() { + var handlers = { + 'undo': function () { Doc.undo(); }, + 'redo': function () { Doc.redo(); }, + 'add-text': function () { Canvas.Edit.open(Canvas.Actions.addText().id); }, + 'add-file': function () { Canvas.Actions.addFile(); }, + 'add-link': function () { Canvas.Actions.addLink(); }, + 'add-group': function () { Canvas.Actions.addGroup(); }, + 'delete-selection': function () { Canvas.Actions.deleteSelection(); }, + 'zoom-in': function () { View.zoomBy(1.2); }, + 'zoom-out': function () { View.zoomBy(1 / 1.2); }, + 'zoom-reset': function () { View.resetZoom(); }, + 'fit': function () { View.fit(Doc.nodes()); }, + 'canvas-save': function () { Save.run(); }, + 'canvas-rename': renameCanvas, + 'canvas-delete': deleteCanvas + }; + + document.addEventListener('click', function (e) { + var btn = e.target.closest('[data-action]'); + if (!btn) return; + var fn = handlers[btn.dataset.action]; + if (!fn) return; + e.preventDefault(); + fn(); + }); + + document.querySelectorAll('.canvas-color-menu [data-color]').forEach(function (btn) { + btn.addEventListener('click', function () { + Canvas.Actions.setColor(btn.dataset.color); + }); + }); + + // The header dropdowns are wired on demand: the auto-wiring in + // global-shortcuts.js only covers the page's ACTIONS menu, which this + // view replaces. + ['canvas-menu', 'color-menu'].forEach(function (action) { + wireDropdown(document.querySelector('[data-action="' + action + '"]')); + }); + + document.querySelectorAll('.canvas-tabs a').forEach(function (a) { + a.addEventListener('click', function (e) { + if (!leaveGuard()) e.preventDefault(); + }); + }); + } + + // typing reports whether the keystroke belongs to a text field, an open + // modal, or the node editor — in which case the canvas must keep its hands + // off it. + function typing(e) { + var t = e.target; + if (!t || !t.closest) return false; + return !!t.closest('input, textarea, select, .cm-editor, .modal, [contenteditable="true"]'); + } + + function wireKeys() { + document.addEventListener('keydown', function (e) { + if (typing(e)) return; + + var mod = e.ctrlKey || e.metaKey; + if (mod && e.key.toLowerCase() === 's') { + e.preventDefault(); + Save.run(); + return; + } + if (mod && e.key.toLowerCase() === 'z') { + e.preventDefault(); + if (e.shiftKey) Doc.redo(); else Doc.undo(); + return; + } + if (mod && e.key.toLowerCase() === 'y') { + e.preventDefault(); + Doc.redo(); + return; + } + if (mod && e.key.toLowerCase() === 'a') { + e.preventDefault(); + Canvas.Actions.selectAll(); + return; + } + if (mod) return; + + switch (e.key) { + case 'Delete': + case 'Backspace': + e.preventDefault(); + Canvas.Actions.deleteSelection(); + break; + case 'Escape': + if (!Canvas.Interact.cancel()) Select.clear(); + break; + case 'Enter': + var sel = Select.nodes(); + if (sel.length === 1) { + e.preventDefault(); + Canvas.Edit.open(sel[0]); + } + break; + case 'ArrowUp': e.preventDefault(); Canvas.Actions.nudge(0, -1, e.shiftKey); break; + case 'ArrowDown': e.preventDefault(); Canvas.Actions.nudge(0, 1, e.shiftKey); break; + case 'ArrowLeft': e.preventDefault(); Canvas.Actions.nudge(-1, 0, e.shiftKey); break; + case 'ArrowRight': e.preventDefault(); Canvas.Actions.nudge(1, 0, e.shiftKey); break; + case 't': Canvas.Edit.open(Canvas.Actions.addText().id); break; + case 'f': Canvas.Actions.addFile(); break; + case 'l': Canvas.Actions.addLink(); break; + case 'g': Canvas.Actions.addGroup(); break; + case '9': View.fit(Doc.nodes()); break; + case '0': View.resetZoom(); break; + case '+': + case '=': View.zoomBy(1.2); break; + case '-': View.zoomBy(1 / 1.2); break; + } + }); + } + + // --- boot --- + + Doc.init(data.doc, data.hash); + Nodes.seed(data.rendered, Doc.get()); + Nodes.init(document.getElementById('canvas-nodes'), document.getElementById('canvas-groups')); + Edges.init(document.getElementById('canvas-edges')); + View.init(surface, world); + Canvas.Interact.init(surface, marquee); + Save.init(data.postUrl, data.name); + + Doc.onChange(refresh); + Select.onChange(function () { + Nodes.syncSelection(); + Edges.syncSelection(); + updateToolbar(); + }); + View.onChange(updateZoom); + Save.onState(function (state, message) { setStatus(message); }); + + wireToolbar(); + wireKeys(); + + refresh(); + View.fit(Doc.nodes()); + updateZoom(); + surface.focus(); +})(); diff --git a/assets/canvas/nodes.js b/assets/canvas/nodes.js new file mode 100644 index 0000000..2c3e5fc Binary files /dev/null and b/assets/canvas/nodes.js differ diff --git a/assets/canvas/save.js b/assets/canvas/save.js new file mode 100644 index 0000000..c03105a --- /dev/null +++ b/assets/canvas/save.js @@ -0,0 +1,125 @@ +// save.js — writing the canvas back. +// +// The whole document goes up in one request: a canvas is a single JSON object +// whose edges reference node ids, so a partial patch would have to reason about +// cross-references the server would then have to re-validate anyway. +// +// Two server rejections are worth handling rather than dumping on the user: +// a 409 (someone else changed the file) and the empty-canvas guard (which asks +// for explicit confirmation before letting a save blank a populated canvas). +window.Canvas = window.Canvas || {}; + +Canvas.Save = (function () { + var Doc = Canvas.Doc; + + var action = ''; + var saving = false; + var listeners = []; + + function emit(state, message) { + for (var i = 0; i < listeners.length; i++) listeners[i](state, message); + } + + function post(url) { + return fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Canvas-Version': Doc.version() + }, + body: JSON.stringify(Doc.get()) + }); + } + + function run(force) { + if (saving) return Promise.resolve(false); + if (!Doc.isDirty() && !force) { + emit('clean', 'no changes'); + return Promise.resolve(true); + } + saving = true; + emit('saving', 'saving…'); + + var url = action + (force ? '&empty=1' : ''); + return post(url).then(function (res) { + if (res.status === 204) { + Doc.markSaved(res.headers.get('X-Canvas-Version') || ''); + emit('clean', 'saved'); + return true; + } + return res.text().then(function (msg) { + if (res.status === 409) { + conflict(msg); + } else if (res.status === 400 && msg.indexOf('empty canvas') !== -1) { + confirmEmpty(msg); + } else { + emit('error', msg || ('save failed (' + res.status + ')')); + alert(msg || ('Save failed (' + res.status + ')')); + } + return false; + }); + }).catch(function () { + emit('error', 'network error'); + alert('Could not reach the server — your changes are still here, try again.'); + return false; + }).finally(function () { + saving = false; + }); + } + + // conflict: the file changed underneath us. Overwriting silently would + // discard whatever the other writer did, so the choice is the user's — and + // reloading would discard *their* work, so that is spelled out too. + function conflict(msg) { + emit('error', 'conflict'); + openModal({ + title: 'Canvas changed on disk', + body: msg + '\n\nReload to take the version on disk (your unsaved changes here are lost), ' + + 'or keep editing and save again to overwrite it.', + confirm: { + label: 'RELOAD', + danger: true, + onConfirm: function () { + closeModal(); + window.location.reload(); + } + }, + cancel: { label: 'KEEP EDITING', autofocus: true } + }); + } + + function confirmEmpty(msg) { + emit('error', 'refused'); + openModal({ + title: 'Save an empty canvas?', + body: msg + '\n\nThis removes every node from the file.', + confirm: { + label: 'EMPTY IT', + danger: true, + onConfirm: function () { + closeModal(); + run(true); + } + } + }); + } + + return { + init: function (postURL, name) { + action = postURL + '?canvas=' + encodeURIComponent(name); + // Unsaved work must not vanish on a stray Back or tab close. The + // browser shows its own generic wording; the return value is only a + // signal that there is something to lose. + window.addEventListener('beforeunload', function (e) { + if (!Doc.isDirty()) return; + e.preventDefault(); + e.returnValue = ''; + }); + }, + + run: function () { return run(false); }, + isSaving: function () { return saving; }, + onState: function (fn) { listeners.push(fn); } + }; +})(); diff --git a/assets/canvas/select.js b/assets/canvas/select.js new file mode 100644 index 0000000..c978f96 --- /dev/null +++ b/assets/canvas/select.js @@ -0,0 +1,77 @@ +// select.js — which nodes and edges are selected. +// +// Kept apart from the document so selection never lands in the undo stack or +// on disk: undoing an edit should restore geometry, not re-select whatever +// happened to be highlighted at the time. +window.Canvas = window.Canvas || {}; + +Canvas.Select = (function () { + var nodes = {}; + var edges = {}; + var listeners = []; + + function emit() { + for (var i = 0; i < listeners.length; i++) listeners[i](); + } + + function keys(obj) { return Object.keys(obj); } + + return { + nodes: function () { return keys(nodes); }, + edges: function () { return keys(edges); }, + hasNode: function (id) { return !!nodes[id]; }, + hasEdge: function (id) { return !!edges[id]; }, + isEmpty: function () { return !keys(nodes).length && !keys(edges).length; }, + count: function () { return keys(nodes).length + keys(edges).length; }, + + set: function (nodeIds, edgeIds) { + nodes = {}; + edges = {}; + (nodeIds || []).forEach(function (id) { nodes[id] = true; }); + (edgeIds || []).forEach(function (id) { edges[id] = true; }); + emit(); + }, + + addNode: function (id) { + if (nodes[id]) return; + nodes[id] = true; + emit(); + }, + + toggleNode: function (id) { + if (nodes[id]) delete nodes[id]; else nodes[id] = true; + emit(); + }, + + toggleEdge: function (id) { + if (edges[id]) delete edges[id]; else edges[id] = true; + emit(); + }, + + clear: function () { + if (!keys(nodes).length && !keys(edges).length) return; + nodes = {}; + edges = {}; + emit(); + }, + + // prune drops ids that no longer exist. Called after undo/redo, where + // the document can lose nodes the selection still refers to. + prune: function (doc) { + var changed = false; + var live = {}; + doc.nodes.forEach(function (n) { live[n.id] = true; }); + keys(nodes).forEach(function (id) { + if (!live[id]) { delete nodes[id]; changed = true; } + }); + var liveEdges = {}; + doc.edges.forEach(function (e) { liveEdges[e.id] = true; }); + keys(edges).forEach(function (id) { + if (!liveEdges[id]) { delete edges[id]; changed = true; } + }); + if (changed) emit(); + }, + + onChange: function (fn) { listeners.push(fn); } + }; +})(); diff --git a/assets/canvas/view.js b/assets/canvas/view.js new file mode 100644 index 0000000..19b8744 --- /dev/null +++ b/assets/canvas/view.js @@ -0,0 +1,147 @@ +// view.js — pan and zoom. +// +// The whole scene lives in one transformed
, so panning and zooming cost a +// single CSS transform: no relayout, no re-render of node content, nothing that +// touches the DOM per node. That is what keeps a few hundred nodes usable on +// the mobile/VPN path. +// +// The dot grid is painted on the (untransformed) surface and its size/offset +// follow the transform, which keeps it crisp at every zoom level instead of +// scaling a bitmap. +window.Canvas = window.Canvas || {}; + +Canvas.View = (function () { + var MIN_SCALE = 0.05; + var MAX_SCALE = 4; + var GRID = 20; + + var surface = null; + var world = null; + var tx = 0, ty = 0, scale = 1; + var listeners = []; + + function apply() { + world.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')'; + var step = GRID * scale; + surface.style.backgroundSize = step + 'px ' + step + 'px'; + surface.style.backgroundPosition = tx + 'px ' + ty + 'px'; + // Below a few pixels apart the dots stop reading as a grid and start + // reading as noise, so zooming far out drops them entirely. + surface.classList.toggle('is-grid-hidden', step < 6); + for (var i = 0; i < listeners.length; i++) listeners[i](); + } + + function clampScale(s) { + return Math.max(MIN_SCALE, Math.min(MAX_SCALE, s)); + } + + // zoomAt scales about a screen point, so the world coordinate under the + // cursor stays put — the behaviour that makes wheel-zoom feel anchored + // rather than lurching toward the origin. + function zoomAt(clientX, clientY, factor) { + var next = clampScale(scale * factor); + if (next === scale) return; + var r = surface.getBoundingClientRect(); + var px = clientX - r.left; + var py = clientY - r.top; + var ratio = next / scale; + tx = px - (px - tx) * ratio; + ty = py - (py - ty) * ratio; + scale = next; + apply(); + } + + function bounds(nodes) { + if (!nodes.length) return null; + var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodes.forEach(function (n) { + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + n.width); + maxY = Math.max(maxY, n.y + n.height); + }); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + return { + GRID: GRID, + + init: function (surfaceEl, worldEl) { + surface = surfaceEl; + world = worldEl; + apply(); + }, + + get: function () { return { x: tx, y: ty, scale: scale }; }, + scale: function () { return scale; }, + + toWorld: function (clientX, clientY) { + var r = surface.getBoundingClientRect(); + return { + x: (clientX - r.left - tx) / scale, + y: (clientY - r.top - ty) / scale + }; + }, + + toScreen: function (x, y) { + var r = surface.getBoundingClientRect(); + return { x: x * scale + tx + r.left, y: y * scale + ty + r.top }; + }, + + pan: function (dx, dy) { + tx += dx; + ty += dy; + apply(); + }, + + zoomAt: zoomAt, + + // zoomBy scales about the viewport centre, for the toolbar buttons and + // the +/- shortcuts where there is no cursor to anchor to. + zoomBy: function (factor) { + var r = surface.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, factor); + }, + + resetZoom: function () { + var r = surface.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / scale); + }, + + // fit frames the whole document with a margin, never zooming past 1:1 — + // a canvas with three small nodes should not open comically magnified. + fit: function (nodes) { + var b = bounds(nodes); + var r = surface.getBoundingClientRect(); + if (!b || !r.width || !r.height) { + tx = 0; ty = 0; scale = 1; + apply(); + return; + } + var pad = 60; + var s = clampScale(Math.min( + (r.width - pad * 2) / Math.max(b.width, 1), + (r.height - pad * 2) / Math.max(b.height, 1), + 1 + )); + scale = s; + tx = r.width / 2 - (b.x + b.width / 2) * s; + ty = r.height / 2 - (b.y + b.height / 2) * s; + apply(); + }, + + // centreOfViewport is where a newly added node goes: wherever the user + // is currently looking. + centreOfViewport: function () { + var r = surface.getBoundingClientRect(); + return { + x: (r.width / 2 - tx) / scale, + y: (r.height / 2 - ty) / scale + }; + }, + + bounds: bounds, + onChange: function (fn) { listeners.push(fn); }, + refresh: apply + }; +})(); diff --git a/assets/icons/canvas.svg b/assets/icons/canvas.svg new file mode 100644 index 0000000..783f714 --- /dev/null +++ b/assets/icons/canvas.svg @@ -0,0 +1,4 @@ + + + diff --git a/assets/layout.html b/assets/layout.html index 23c1a51..3c55055 100644 --- a/assets/layout.html +++ b/assets/layout.html @@ -15,7 +15,7 @@ - {{if not .EditMode}} + {{if .ShowPageChrome}} {{end}} {{block "headScripts" .}}{{end}} @@ -24,13 +24,13 @@ - {{if not .EditMode}} + {{if .ShowPageChrome}}
{{end}}
- {{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}} + {{if not .ShowPageChrome}}{{block "headerActions" .}}{{end}}{{else}}
- {{if not .EditMode}}
{{block "extras" .}}{{end}} diff --git a/assets/page/canvas-actions.js b/assets/page/canvas-actions.js new file mode 100644 index 0000000..a66b92c --- /dev/null +++ b/assets/page/canvas-actions.js @@ -0,0 +1,43 @@ +// canvas-actions.js — creating a canvas from a normal page. +// +// The canvas view itself owns everything else (rename, delete, editing); this +// only covers the one action that has to start from the page, since a folder +// with no canvases has no canvas view to reach. +// +// Reuses openModal and postReplace from page/actions.js, which the page +// template loads first. + +// validCanvasName mirrors canvasNameRe in canvas.go. Checking here only buys a +// better error message — the server rejects the same set regardless. +function validCanvasName(name) { + if (!name || name.length > 100) return false; + if (name.charAt(0) === '.') return false; + return !/[\/\\]/.test(name); +} + +function newCanvas() { + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'input'; + input.placeholder = 'Canvas name'; + + openModal({ + title: 'New canvas', + body: input, + confirm: { + label: 'CREATE', + onConfirm: function () { + var name = input.value.trim(); + if (!validCanvasName(name)) { + alert('A canvas name cannot be empty, start with a dot, or contain a slash.'); + return; + } + closeModal(); + var base = window.location.pathname; + if (base.slice(-1) !== '/') base += '/'; + var target = base + '?canvas=' + encodeURIComponent(name); + postReplace(target + '&create=1', null, target); + } + } + }); +} diff --git a/assets/page/main.html b/assets/page/main.html index 0c4f71c..5535f67 100644 --- a/assets/page/main.html +++ b/assets/page/main.html @@ -1,4 +1,5 @@ -{{define "headScripts"}}{{end}} +{{define "headScripts"}} +{{end}} {{define "headerActions"}}{{if .CanEdit}} @@ -8,9 +9,20 @@ {{end}} EDIT + {{end}}{{end}} {{define "content"}} +{{/* Canvases in this folder are alternative views of the same page, so they + read as a tab strip above the content rather than as rows in the file + listing (listEntries filters them out for exactly this reason). */}} +{{if .Canvases}} + +{{end}} {{if .Content}}
{{.Content}}
{{end}} diff --git a/assets/style.css b/assets/style.css index aca47fc..edad2a7 100644 --- a/assets/style.css +++ b/assets/style.css @@ -26,6 +26,17 @@ --danger-hover: #d03467; --overlay: rgba(0, 0, 0, 0.55); + /* JSON Canvas preset colours "1".."6" (red, orange, yellow, green, cyan, + purple), pulled toward the app palette so a canvas does not look like a + different program. Hex colours in a .canvas file pass through untouched. */ + --canvas-1: #e05555; + --canvas-2: #e08a3c; + --canvas-3: #d9b03c; + --canvas-4: #5aab64; + --canvas-5: #01b6c4; + --canvas-6: #a069c4; + --canvas-edge: #9a9a9a; + --border: 1px solid var(--secondary); --border-dashed: 1px dashed var(--secondary); --border-guide: 1px solid var(--bg-panel-hover); @@ -889,3 +900,319 @@ aside.sidebar:empty { display: none; } .list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; } .toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);} } + +/* === Canvas === + A canvas is an alternative view of a page (see canvas.go). The tab strip is + shared with the normal page template; everything below it only exists in + canvas mode. */ + +/* --- view switcher (rendered on normal pages too) --- */ +.canvas-tabs { + gap: 0; + flex-wrap: wrap; + border-bottom: var(--border-dashed); + margin-bottom: var(--space-4); +} +.canvas-tab { + padding: var(--space-2) var(--space-3); + font-size: var(--font-sm); + color: var(--text-muted); + background: none; + border: 1px solid transparent; + border-bottom: none; + /* Overlap the strip's own border so the active tab joins it. */ + margin-bottom: -1px; + font: inherit; + font-size: var(--font-sm); + cursor: pointer; +} +.canvas-tab:hover { color: var(--link-hover); } +.canvas-tab.is-active { + color: var(--text); + border-color: var(--secondary); + background: var(--bg-panel); +} +.canvas-tab-add { color: var(--secondary); } + +/* --- full-bleed view mode --- + The canvas owns the viewport instead of the centred reading column: main + loses its max-width and padding, and the centre column stops scrolling so the + surface can handle pan itself. */ +body.canvas-mode .center { overflow: hidden; } +body.canvas-mode main { + max-width: none; + padding: 0; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} +body.canvas-mode footer { max-width: none; } +body.canvas-mode .canvas-tabs { margin: 0; padding: var(--space-2) var(--space-4) 0; } + +.canvas-app { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + +.canvas-toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + padding: 0.4rem 0.6rem; + background: var(--bg-panel-hover); + border-top: var(--border); + border-bottom: var(--border); + flex: 0 0 auto; +} +.canvas-status { margin-left: auto; padding-left: var(--space-3); } +.canvas-swatch { + display: inline-block; + width: 0.7em; + height: 0.7em; + vertical-align: middle; + margin-right: 0.3em; + border: 1px solid var(--bg); +} +.canvas-swatch[data-swatch="1"] { background: var(--canvas-1); } +.canvas-swatch[data-swatch="2"] { background: var(--canvas-2); } +.canvas-swatch[data-swatch="3"] { background: var(--canvas-3); } +.canvas-swatch[data-swatch="4"] { background: var(--canvas-4); } +.canvas-swatch[data-swatch="5"] { background: var(--canvas-5); } +.canvas-swatch[data-swatch="6"] { background: var(--canvas-6); } + +/* --- surface, world, layers --- + touch-action:none hands every touch gesture to the pointer handlers; without + it the browser claims one-finger drags for scrolling and two-finger for page + zoom, and neither reaches the canvas. */ +.canvas-surface { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + touch-action: none; + outline: none; + cursor: grab; + background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1px); + background-repeat: repeat; +} +.canvas-surface.is-grid-hidden { background-image: none; } + +/* The world is a zero-size anchor: all of its content is absolutely positioned + in world coordinates and the whole thing is moved by one transform, so pan + and zoom never touch a single node's layout. */ +.canvas-world { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + transform-origin: 0 0; + will-change: transform; +} +.canvas-layer { position: absolute; top: 0; left: 0; } +/* Drawn in world coordinates with no viewport of its own; overflow:visible is + what lets it paint outside its 1x1 box, including at negative coordinates. */ +.canvas-edges { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + overflow: visible; +} + +/* --- nodes --- */ +.canvas-node { + position: absolute; + display: flex; + flex-direction: column; + border: 1px solid var(--node-accent, var(--secondary)); + background: var(--bg-panel); + /* Visible so the connection ports and resize handles, which sit on the + border, are not clipped; the body clips its own content instead. */ + overflow: visible; + contain: layout paint; +} +.canvas-node[data-color="1"] { --node-accent: var(--canvas-1); } +.canvas-node[data-color="2"] { --node-accent: var(--canvas-2); } +.canvas-node[data-color="3"] { --node-accent: var(--canvas-3); } +.canvas-node[data-color="4"] { --node-accent: var(--canvas-4); } +.canvas-node[data-color="5"] { --node-accent: var(--canvas-5); } +.canvas-node[data-color="6"] { --node-accent: var(--canvas-6); } +.canvas-node.is-selected { outline: 2px solid var(--primary-hover); outline-offset: 1px; } +.canvas-node.is-edge-target { outline: 2px dashed var(--link); outline-offset: 1px; } + +.canvas-node-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: var(--space-3); + font-size: var(--font-sm); +} +.canvas-node-body.content { margin-bottom: 0; } +.canvas-node-body > :first-child { margin-top: 0; } +.canvas-node-body > :last-child { margin-bottom: 0; } +.canvas-node-body h1 { font-size: 1.1rem; } +.canvas-node-body h2 { font-size: 1rem; } +.canvas-node-body h3 { font-size: 0.95rem; } +/* Task checkboxes are rendered enabled for pages, where page/tasks.js writes + the toggle back to index.md. Inside a canvas node there is no such wiring and + the markdown lives in the .canvas file, so a click would be a lie. */ +.canvas-node-body .task-checkbox { pointer-events: none; } + +.canvas-node-title { + flex: 0 0 auto; + padding: var(--space-1) var(--space-3); + font-size: var(--font-xs); + color: var(--text-muted); + border-bottom: var(--border-dashed); +} + +/* A transcluded image or player fills its node edge to edge. */ +.canvas-node-body:has(> .canvas-media) { padding: 0; overflow: hidden; } +.canvas-media { display: block; width: 100%; height: 100%; } +.canvas-media img { display: block; width: 100%; height: 100%; object-fit: contain; } +.canvas-broken { color: var(--danger-hover); } +.canvas-file-fallback { padding: var(--space-2) 0; } + +.canvas-link-card { + display: flex; + flex-direction: column; + gap: var(--space-1); + align-items: flex-start; + max-width: 100%; +} +.canvas-link-host { color: var(--link); } +.canvas-link-url { font-size: var(--font-xs); color: var(--text-muted); max-width: 100%; } +.link-favicon { vertical-align: middle; margin-right: 3px; } + +.canvas-node-group { + background: rgba(255, 255, 255, 0.03); + border: 1px dashed var(--node-accent, var(--bg-panel-hover)); + background-position: center; +} +.canvas-node-group[data-bg-style="cover"] { background-size: cover; } +.canvas-node-group[data-bg-style="ratio"] { background-size: contain; background-repeat: no-repeat; } +.canvas-node-group[data-bg-style="repeat"] { background-repeat: repeat; } +.canvas-node-group .canvas-node-body { display: none; } +.canvas-group-label { + padding: var(--space-2) var(--space-3); + font-size: var(--font-sm); + color: var(--text-muted); +} + +/* --- ports and handles --- + Both are positioned on the node border (the negative margin centres them on + it) and stay invisible until the node is hovered or selected, so a dense + canvas is not covered in dots. */ +.canvas-port { + position: absolute; + width: 12px; + height: 12px; + margin: -6px; + border-radius: 50%; + background: var(--bg); + border: 1px solid var(--secondary); + opacity: 0; + cursor: crosshair; +} +.canvas-node:hover .canvas-port, +.canvas-node.is-selected .canvas-port { opacity: 1; } +.canvas-port:hover { background: var(--primary-hover); } +.canvas-port-top { top: 0; left: 50%; } +.canvas-port-bottom { top: 100%; left: 50%; } +.canvas-port-left { top: 50%; left: 0; } +.canvas-port-right { top: 50%; left: 100%; } + +.canvas-handle { + position: absolute; + width: 10px; + height: 10px; + margin: -5px; + background: var(--primary-hover); + opacity: 0; +} +.canvas-node.is-selected .canvas-handle { opacity: 1; } +.canvas-handle-nw { top: 0; left: 0; cursor: nwse-resize; } +.canvas-handle-ne { top: 0; left: 100%; cursor: nesw-resize; } +.canvas-handle-se { top: 100%; left: 100%; cursor: nwse-resize; } +.canvas-handle-sw { top: 100%; left: 0; cursor: nesw-resize; } + +/* --- edges --- + The hit path is a fat transparent stroke under the visible curve: a 2px + bezier is effectively unclickable without it. */ +.canvas-edge-hit { + stroke: transparent; + stroke-width: 16; + pointer-events: stroke; + cursor: pointer; +} +.canvas-edge-line { stroke-width: 2; pointer-events: none; } +.canvas-edge-head { pointer-events: none; } +.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); stroke-width: 3; } +.canvas-edge.is-selected .canvas-edge-head { fill: var(--primary-hover); } +.canvas-edge-label { + font-family: "Iosevka Etoile", monospace; + font-size: 12px; + fill: var(--text); + /* A stroke painted under the fill gives the label a halo against whatever + it overlaps, without measuring a background rect. */ + stroke: var(--bg); + stroke-width: 4; + paint-order: stroke; + pointer-events: none; +} +.canvas-edge-temp { + stroke: var(--primary-hover); + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; +} + +/* --- overlays --- */ +.canvas-marquee { + position: absolute; + border: 1px dashed var(--primary-hover); + background: rgba(214, 77, 149, 0.12); + pointer-events: none; +} +.canvas-hint { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 30rem; + padding: var(--space-4); + text-align: center; + pointer-events: none; +} + +/* The node editor is fixed to the viewport rather than mounted in the zoomed + world: CodeMirror measures itself in device pixels and a scaled ancestor + breaks its caret and selection maths. */ +.canvas-editor { + position: fixed; + z-index: 60; + display: flex; + flex-direction: column; +} +.canvas-editor .panel-header { padding: var(--space-2) var(--space-3); margin-bottom: 0; } +.canvas-editor-mount { flex: 1 1 auto; min-height: 0; overflow: auto; } +.canvas-editor-mount .cm-editor { height: 100%; } +/* The shared editor theme sets a 60vh floor for the full-page editor; in a + node-sized panel that would force a scrollbar at every size. The theme is + injected at runtime and lands after this sheet, so it needs overriding. */ +.canvas-editor-mount .cm-scroller { min-height: 0 !important; } + +@media (max-width: 600px) { + .canvas-toolbar { gap: var(--space-2); padding: var(--space-2); } + .canvas-toolbar .btn-tool { padding: 0.2rem 0.4rem; } + body.canvas-mode .canvas-tabs { padding: var(--space-2) var(--space-2) 0; } + /* Touch has no hover, so ports would never appear; show them on selection + only and make both targets finger-sized. */ + .canvas-port { width: 16px; height: 16px; margin: -8px; } + .canvas-handle { width: 14px; height: 14px; margin: -7px; } +} diff --git a/canvas.go b/canvas.go new file mode 100644 index 0000000..78b1479 --- /dev/null +++ b/canvas.go @@ -0,0 +1,699 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "html/template" + "io" + "log" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// JSON Canvas (https://jsoncanvas.org/spec/1.0/) support. +// +// A canvas is a `.canvas` file sitting in a page folder, addressed as an +// alternative *view* of that page rather than as a standalone file: +// +// /Topics/Ideas/?canvas=Architecture the canvas view +// /Topics/Ideas/Architecture.canvas 302 -> the line above +// /Topics/Ideas/Architecture.canvas?raw the file bytes +// +// Routing through the folder keeps the page's identity (breadcrumb, tree, +// header actions) and lets every canvas in a folder render as a tab strip above +// the page content. The redirect mirrors how diary.go collapses its virtual +// month/day URLs onto one canonical form. +// +// The server owns the format: it parses and validates on every read and write, +// re-marshals from its own structs on save, and never writes bytes it has not +// round-tripped through canvasDoc. Keys it does not model are dropped rather +// than preserved — this wiki is the only writer. + +const canvasExt = ".canvas" + +// canvasMaxBytes caps a save payload. A canvas is coordinates and prose; a +// megabyte is already far past any hand-built diagram and well short of +// anything that could pressure the NAS. +const canvasMaxBytes = 4 << 20 + +// canvasRenderMaxBytes caps a /_canvas/render body. Only one node's content +// travels at a time. +const canvasRenderMaxBytes = 1 << 20 + +// Structural caps. These exist so a looping client cannot grow a file until it +// stops loading, not because the format has limits. +const ( + canvasMaxNodes = 5000 + canvasMaxEdges = 10000 + canvasMaxCoord = 1 << 20 +) + +// canvasNameRe restricts a ?canvas= value to a single safe path component: the +// name is joined onto the folder path, so separators, dot-segments, control +// characters, and leading dots (which would create a hidden file the listing +// filters away) must all be rejected before the filesystem sees it. +var canvasNameRe = regexp.MustCompile(`^[^./\\\x00-\x1f][^/\\\x00-\x1f]{0,99}$`) + +// canvasHexRe matches the hex form of a canvasColor. The spec also allows the +// preset digits "1".."6"; see validCanvasColor. +var canvasHexRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// Node types defined by the spec. +const ( + canvasNodeText = "text" + canvasNodeFile = "file" + canvasNodeLink = "link" + canvasNodeGroup = "group" +) + +// canvasDoc is a whole `.canvas` file. Both arrays are optional per the spec, +// so an empty document marshals to `{}`. +type canvasDoc struct { + Nodes []canvasNode `json:"nodes,omitempty"` + Edges []canvasEdge `json:"edges,omitempty"` +} + +// canvasNode carries every node variant in one struct. The spec's four types +// share six required attributes and add one to three of their own, so a flat +// struct with omitempty round-trips all of them without the ceremony of a +// custom unmarshaller; validate() enforces which extras a given Type may use. +type canvasNode struct { + ID string `json:"id"` + Type string `json:"type"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Color string `json:"color,omitempty"` + + Text string `json:"text,omitempty"` // type=text + File string `json:"file,omitempty"` // type=file + Subpath string `json:"subpath,omitempty"` // type=file + URL string `json:"url,omitempty"` // type=link + Label string `json:"label,omitempty"` // type=group + Background string `json:"background,omitempty"` // type=group + BackgroundStyle string `json:"backgroundStyle,omitempty"` // type=group +} + +type canvasEdge struct { + ID string `json:"id"` + FromNode string `json:"fromNode"` + FromSide string `json:"fromSide,omitempty"` + FromEnd string `json:"fromEnd,omitempty"` // defaults to "none" + ToNode string `json:"toNode"` + ToSide string `json:"toSide,omitempty"` + ToEnd string `json:"toEnd,omitempty"` // defaults to "arrow" + Color string `json:"color,omitempty"` + Label string `json:"label,omitempty"` +} + +// canvasRef is one entry in the page's view switcher. +type canvasRef struct { + Name string + URL string + Active bool +} + +// canvasPayload is the single blob inlined into the canvas page. Shipping the +// document *and* its server-rendered node HTML together means opening a canvas +// costs exactly one request no matter how many nodes it holds — the obvious +// alternative (one content request per node on load) fans out badly over the +// mobile/VPN path. +type canvasPayload struct { + Name string `json:"name"` + PostURL string `json:"postUrl"` + Hash string `json:"hash"` + Doc *canvasDoc `json:"doc"` + Rendered map[string]template.HTML `json:"rendered"` +} + +// --- parsing and validation --- + +// parseCanvas unmarshals and validates raw canvas bytes. Unknown keys are +// ignored rather than rejected so a hand-edited file with a stray attribute +// still opens; they are dropped on the next save. +func parseCanvas(raw []byte) (*canvasDoc, error) { + doc := &canvasDoc{} + if len(strings.TrimSpace(string(raw))) == 0 { + return doc, nil + } + if err := json.Unmarshal(raw, doc); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + if err := doc.validate(); err != nil { + return nil, err + } + return doc, nil +} + +// validate rejects anything that would render as a broken or invisible canvas. +// It runs on read as well as write: a file edited by hand outside the app gets +// the same diagnosis the editor would give, naming the offending node. +func (c *canvasDoc) validate() error { + if len(c.Nodes) > canvasMaxNodes { + return fmt.Errorf("too many nodes (%d, max %d)", len(c.Nodes), canvasMaxNodes) + } + if len(c.Edges) > canvasMaxEdges { + return fmt.Errorf("too many edges (%d, max %d)", len(c.Edges), canvasMaxEdges) + } + + ids := make(map[string]bool, len(c.Nodes)) + for i := range c.Nodes { + n := &c.Nodes[i] + if n.ID == "" { + return fmt.Errorf("node %d: missing id", i) + } + if ids[n.ID] { + return fmt.Errorf("duplicate node id %q", n.ID) + } + ids[n.ID] = true + + switch n.Type { + case canvasNodeText: + // An empty text node is a node the user just created and has not + // typed into yet, so emptiness is allowed here even though the spec + // calls `text` required. A missing *file* or *url*, by contrast, is + // a node that can never render anything. + case canvasNodeFile: + if n.File == "" { + return fmt.Errorf("node %q: file node has no file", n.ID) + } + if n.Subpath != "" && !strings.HasPrefix(n.Subpath, "#") { + return fmt.Errorf("node %q: subpath must start with #", n.ID) + } + case canvasNodeLink: + if n.URL == "" { + return fmt.Errorf("node %q: link node has no url", n.ID) + } + case canvasNodeGroup: + if n.BackgroundStyle != "" { + switch n.BackgroundStyle { + case "cover", "ratio", "repeat": + default: + return fmt.Errorf("node %q: bad backgroundStyle %q", n.ID, n.BackgroundStyle) + } + } + case "": + return fmt.Errorf("node %q: missing type", n.ID) + default: + return fmt.Errorf("node %q: unknown type %q", n.ID, n.Type) + } + + if n.Width <= 0 || n.Height <= 0 { + return fmt.Errorf("node %q: width and height must be positive", n.ID) + } + if n.Width > canvasMaxCoord || n.Height > canvasMaxCoord || + abs(n.X) > canvasMaxCoord || abs(n.Y) > canvasMaxCoord { + return fmt.Errorf("node %q: coordinates out of range", n.ID) + } + if !validCanvasColor(n.Color) { + return fmt.Errorf("node %q: bad color %q", n.ID, n.Color) + } + } + + edgeIDs := make(map[string]bool, len(c.Edges)) + for i := range c.Edges { + e := &c.Edges[i] + if e.ID == "" { + return fmt.Errorf("edge %d: missing id", i) + } + if edgeIDs[e.ID] { + return fmt.Errorf("duplicate edge id %q", e.ID) + } + edgeIDs[e.ID] = true + if !ids[e.FromNode] { + return fmt.Errorf("edge %q: fromNode %q does not exist", e.ID, e.FromNode) + } + if !ids[e.ToNode] { + return fmt.Errorf("edge %q: toNode %q does not exist", e.ID, e.ToNode) + } + if !validCanvasSide(e.FromSide) { + return fmt.Errorf("edge %q: bad fromSide %q", e.ID, e.FromSide) + } + if !validCanvasSide(e.ToSide) { + return fmt.Errorf("edge %q: bad toSide %q", e.ID, e.ToSide) + } + if !validCanvasEnd(e.FromEnd) { + return fmt.Errorf("edge %q: bad fromEnd %q", e.ID, e.FromEnd) + } + if !validCanvasEnd(e.ToEnd) { + return fmt.Errorf("edge %q: bad toEnd %q", e.ID, e.ToEnd) + } + if !validCanvasColor(e.Color) { + return fmt.Errorf("edge %q: bad color %q", e.ID, e.Color) + } + } + return nil +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// validCanvasColor accepts an absent color, a preset digit "1".."6" (red, +// orange, yellow, green, cyan, purple), or a hex string. +func validCanvasColor(c string) bool { + switch c { + case "", "1", "2", "3", "4", "5", "6": + return true + } + return canvasHexRe.MatchString(c) +} + +func validCanvasSide(s string) bool { + switch s { + case "", "top", "right", "bottom", "left": + return true + } + return false +} + +func validCanvasEnd(e string) bool { + switch e { + case "", "none", "arrow": + return true + } + return false +} + +// canvasHash is the conflict token exchanged with the client. It covers the +// exact bytes on disk, so any edit made outside this editor invalidates it. +func canvasHash(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:])[:16] +} + +// --- paths and discovery --- + +func canvasPath(fsPath, name string) string { + return filepath.Join(fsPath, name+canvasExt) +} + +// canvasBackupPath is the previous-contents copy kept beside a canvas. The dot +// prefix keeps it out of the file listing and the search index. +func canvasBackupPath(fsPath, name string) string { + return filepath.Join(fsPath, "."+name+canvasExt+".bak") +} + +// canvasURL builds the canonical view URL for a canvas in the folder at urlPath. +func canvasURL(urlPath, name string) string { + if !strings.HasSuffix(urlPath, "/") { + urlPath += "/" + } + return urlPath + "?canvas=" + url.QueryEscape(name) +} + +// canvasViewURL maps a direct `/A/B/Name.canvas` request onto the canonical +// `/A/B/?canvas=Name` form. Segments are re-encoded because urlPath arrives +// already percent-decoded. +func canvasViewURL(urlPath string) (string, bool) { + base := path.Base(urlPath) + name := strings.TrimSuffix(base, canvasExt) + if name == "" || name == base || !canvasNameRe.MatchString(name) { + return "", false + } + return canvasURL(fileURL(strings.Trim(path.Dir(urlPath), "/")), name), true +} + +// listCanvases returns the canvases in a folder as view-switcher tabs, sorted +// case-insensitively by name. active marks the one currently being viewed. +func listCanvases(fsPath, urlPath, active string) []canvasRef { + entries, err := os.ReadDir(fsPath) + if err != nil { + return nil + } + var refs []canvasRef + for _, e := range entries { + name := e.Name() + if e.IsDir() || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, canvasExt) { + continue + } + base := strings.TrimSuffix(name, canvasExt) + if !canvasNameRe.MatchString(base) { + continue + } + refs = append(refs, canvasRef{ + Name: base, + URL: canvasURL(urlPath, base), + Active: base == active, + }) + } + sort.Slice(refs, func(i, j int) bool { + return strings.ToLower(refs[i].Name) < strings.ToLower(refs[j].Name) + }) + return refs +} + +// isCanvasFile reports whether a listing entry is a canvas. Used to keep +// canvases out of the Files listing — they are surfaced as tabs above the +// content instead, the same reasoning that hides index.md. +func isCanvasFile(name string) bool { + return strings.HasSuffix(name, canvasExt) +} + +// --- server-side node rendering --- + +// prerenderNodes renders every node whose content the server owns, keyed by +// node id. Link and group nodes are built entirely in the browser and are +// absent from the map. +func (h *handler) prerenderNodes(c *canvasDoc) map[string]template.HTML { + out := make(map[string]template.HTML, len(c.Nodes)) + for i := range c.Nodes { + n := &c.Nodes[i] + switch n.Type { + case canvasNodeText: + out[n.ID] = renderMarkdown([]byte(n.Text)) + case canvasNodeFile: + out[n.ID] = h.renderFileNode(n.File, n.Subpath) + } + } + return out +} + +// renderFileNode renders a file node's transclusion. Markdown is rendered +// through the shared goldmark instance so wikilinks, embeds, tables, and task +// checkboxes behave exactly as they do on a page; media gets a player or an +// pointed at the existing thumbnail endpoint; anything else degrades to +// a link. A missing target renders visibly broken rather than blank. +func (h *handler) renderFileNode(file, subpath string) template.HTML { + target := "/" + strings.Trim(file, "/") + fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/"))) + if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") { + return canvasBrokenRef(file, "outside the wiki") + } + + info, err := os.Stat(fsPath) + if err != nil { + return canvasBrokenRef(file, "not found") + } + if info.IsDir() { + // A folder is a page; transclude its markdown. + fsPath = filepath.Join(fsPath, "index.md") + if _, err := os.Stat(fsPath); err != nil { + return canvasBrokenRef(file, "page has no content") + } + return h.renderMarkdownFile(fsPath, file, subpath) + } + + name := path.Base(target) + href := html.EscapeString(fileURL(strings.TrimPrefix(target, "/"))) + switch { + case isImageFile(name): + return template.HTML(``) + case isVideoFile(name): + return template.HTML(``) + case strings.EqualFold(path.Ext(name), ".pdf"): + return template.HTML(``) + case strings.EqualFold(path.Ext(name), ".md"): + return h.renderMarkdownFile(fsPath, file, subpath) + default: + return template.HTML(`

` + + html.EscapeString(name) + `

`) + } +} + +// renderMarkdownFile renders a markdown file, optionally narrowed to the +// section named by subpath (`#Heading`). The narrowing reuses the same section +// machinery the page editor uses, so a heading spans its subsections. +func (h *handler) renderMarkdownFile(fsPath, file, subpath string) template.HTML { + raw, err := os.ReadFile(fsPath) + if err != nil { + return canvasBrokenRef(file, "unreadable") + } + if subpath != "" { + section, ok := canvasSubpathSection(raw, subpath) + if !ok { + return canvasBrokenRef(file+subpath, "no such heading") + } + raw = section + } + return renderMarkdown(raw) +} + +// canvasSubpathSection narrows raw markdown to the section whose heading +// matches subpath. The match accepts either the heading text (what a human +// types) or goldmark's generated anchor id (what a copied link contains). +func canvasSubpathSection(raw []byte, subpath string) ([]byte, bool) { + want := strings.TrimSpace(strings.TrimPrefix(subpath, "#")) + if want == "" { + return raw, true + } + sections := splitSections(raw) + ids := headingIDs(raw) + for i := 1; i < len(sections); i++ { + _, text := sectionHeading(sections[i]) + id := "" + if i-1 < len(ids) { + id = ids[i-1] + } + if strings.EqualFold(text, want) || id == want { + return joinSections(sections[i:secionSpanEnd(sections, i)]), true + } + } + return nil, false +} + +func canvasBrokenRef(target, reason string) template.HTML { + return template.HTML(`

` + html.EscapeString(target) + + ` (` + html.EscapeString(reason) + `)

`) +} + +// --- request handlers --- + +// serveCanvas renders the canvas view of a page. Reached from serveDir when +// the request carries ?canvas=. +func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) { + if !canvasNameRe.MatchString(name) { + http.Error(w, "bad canvas name", http.StatusBadRequest) + return + } + raw, err := os.ReadFile(canvasPath(fsPath, name)) + if err != nil { + if os.IsNotExist(err) { + http.NotFound(w, r) + return + } + http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError) + return + } + + data := pageData{ + Title: name + " — " + pageTitle(urlPath), + CanEdit: true, + IsRoot: urlPath == "/", + SectionIndex: -1, + InsertBefore: -1, + PostURL: urlPath, + ActiveCanvas: name, + Canvases: listCanvases(fsPath, urlPath, name), + } + + // A parse failure is reported in-page rather than as a bare error: the file + // is hand-editable, so the user needs to see which node is wrong and still + // reach the raw bytes. + doc, parseErr := parseCanvas(raw) + if parseErr != nil { + data.CanvasError = parseErr.Error() + } else { + payload := canvasPayload{ + Name: name, + PostURL: urlPath, + Hash: canvasHash(raw), + Doc: doc, + Rendered: h.prerenderNodes(doc), + } + // encoding/json escapes <, >, & and the U+2028/U+2029 line separators, + // so the result is safe to inline verbatim in a