From 2903986f44bd7ad2a27a62934bcd60a3a9013aaf Mon Sep 17 00:00:00 2001 From: luxick Date: Sun, 4 Oct 2026 15:28:06 +0200 Subject: [PATCH] Canvas support V1 --- README.md | 25 +- assets/canvas/canvas.js | 1271 ++++++++++++++++++++++++++++++++++++ assets/canvas/main.html | 83 +++ assets/canvas/node.html | 20 + assets/editor/core.js | 430 ++++++++++++ assets/editor/main.html | 66 +- assets/editor/main.js | 401 +----------- assets/editor/toolbar.html | 67 ++ assets/hx.js | 29 +- assets/icons/canvas.svg | 4 + assets/layout.html | 5 +- assets/page/dialogs.html | 13 + assets/page/main.html | 1 + assets/style.css | 244 ++++++- canvas.go | 572 ++++++++++++++++ canvas_test.go | 250 +++++++ dialogs.go | 2 + e2e/tests/canvas.spec.js | 151 +++++ main.go | 14 +- render.go | 57 +- 20 files changed, 3223 insertions(+), 482 deletions(-) create mode 100644 assets/canvas/canvas.js create mode 100644 assets/canvas/main.html create mode 100644 assets/canvas/node.html create mode 100644 assets/editor/core.js create mode 100644 assets/editor/toolbar.html create mode 100644 assets/icons/canvas.svg create mode 100644 canvas.go create mode 100644 canvas_test.go create mode 100644 e2e/tests/canvas.spec.js diff --git a/README.md b/README.md index 7f19f45..1c50179 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Minimal self-hosted personal wiki. Folders are pages. - **Search**: find pages by name. - **Wikilinks**: `[[query]]` or `[[query::label]]`. They open a search, so links still work after a page is renamed. Typing `[[` in the editor suggests page names. Embed files with `![caption](photo.jpg "left")`. - **Movie import**: create a page from OMDb metadata. Asks once for a free [OMDb API key](https://www.omdbapi.com/apikey.aspx) and keeps it in the browser. +- **Canvases**: [JSON Canvas](https://jsoncanvas.org) boards stored next to a page. See [Canvas](#canvas). - **Special folders**: a [photo diary](#diary) and a [fitness dashboard](#fitness). - **Calendars**: Nextcloud calendars mirrored into the wiki. They add events and weight to the diary, and a [maintenance widget](#calendars) to every page. - **Quick-add bookmarklet**: save the current tab to a wiki page. @@ -54,9 +55,31 @@ All use Alt+Shift. | Where | Keys | |-------|------| -| Anywhere | `E` edit, `N` new page, `M` move, `F` search, `H` maintenance | +| Anywhere | `E` edit (on a canvas: the selected node), `N` new page, `M` move, `F` search, `H` maintenance | | Editor | `S` save, `1`–`3` heading, `B`/`I` bold/italic, `C`/`K` code/code block, `L` link, `P` wikilink, `G` embed, `U`/`O`/`X` bullet/numbered/task list, `Q` quote, `R` rule, `T` format table, `D`/`W` date (ISO/long), `V` movie, `Y` delete line | +## Canvas + +A page can hold any number of canvases: `.canvas` files in its folder, in the [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) format. **CANVAS** in the page actions creates one. Canvases show up in the page's file list. Opening one shows the whole canvas, with no folder tree or sidebar. `?raw` on its URL returns the file itself. + +- **Notes** (text nodes) are markdown, rendered like pages: wikilinks, embeds, tables, task checkboxes. Double-click a note to edit it with the page editor, including its toolbar and shortcuts. Esc or a click outside closes the editor. A note left empty is removed. +- **Files** show images, videos and audio. A markdown file or a page (a folder) is shown rendered, and a `#Heading` subpath narrows it to that section. Anything else shows as a link. Paths are relative to the wiki root. +- **Links** show as a card. **Groups** are labelled frames: moving a group moves what is inside it. +- **Connections**: drag from a node's side handle onto another node. Dropping on empty space creates a new note there. Double-click a connection to label it. + +| Input | | +|-------|-| +| Drag background / Space+drag / middle drag | Pan | +| Wheel, pinch | Zoom (Shift+wheel pans sideways) | +| Shift+drag background | Select an area | +| Double-click background | New note | +| Double-click node, Enter, Alt+Shift+E | Edit (label, URL or file for non-notes) | +| Drag node / corner handle | Move / resize, snapped to a 10 px grid (Alt: no snap) | +| Arrows | Nudge (Shift: 1 px) | +| Del, Ctrl+Z, Ctrl+Y, Ctrl+D, Ctrl+A | Delete, undo, redo, duplicate, select all | + +Changes save automatically. If the file changes elsewhere while a canvas is open, saving stops and asks for a reload instead of overwriting. + ## Diary ``` diff --git a/assets/canvas/canvas.js b/assets/canvas/canvas.js new file mode 100644 index 0000000..4858ea0 --- /dev/null +++ b/assets/canvas/canvas.js @@ -0,0 +1,1271 @@ +// canvas.js — the JSON Canvas editor (canvas/main.html, canvas.go). +// +// The document lives here as the parsed file (#canvas-data); every change +// mutates it in place, so fields this editor does not know survive the +// autosave. Node elements always come from the server: on load they are in +// the page, and a node whose content changed is re-rendered by POST ?render. +// Only geometry, color and selection are applied locally. Edges are drawn +// here, since they follow every drag. +// +// Mouse: drag the background to pan (Shift+drag selects), wheel zooms, +// double-click the background for a new note, double-click a node to edit it, +// drag a node's side handle onto another node to connect them. Touch: one +// finger pans or drags, two fingers pinch. +(function () { + var app = document.querySelector('.canvas-app'); + if (!app) return; + + var viewport = app.querySelector('.canvas-viewport'); + var world = app.querySelector('.canvas-world'); + var nodesEl = app.querySelector('.canvas-nodes'); + var edgesSvg = app.querySelector('.canvas-edges'); + var labelsEl = app.querySelector('.canvas-edge-labels'); + var hintEl = app.querySelector('.canvas-hint'); + var statusEl = app.querySelector('.canvas-status'); + var zoomEl = app.querySelector('.canvas-zoom'); + var nodeToolbar = app.querySelector('.canvas-node-toolbar'); + var readOnly = app.hasAttribute('data-readonly'); + + var doc = JSON.parse(document.getElementById('canvas-data').textContent); + if (!Array.isArray(doc.nodes)) doc.nodes = []; + if (!Array.isArray(doc.edges)) doc.edges = []; + var version = app.dataset.version; + var baseURL = window.location.pathname; + + var GRID = 10; + var MIN_W = 60, MIN_H = 40; + var SIDES = ['top', 'right', 'bottom', 'left']; + var NORMALS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }; + + var byId = {}; // node id → node + var els = {}; // node id → element + var elKey = {}; // node id → content key its element shows + var htmlCache = {}; // content key → element HTML from the server + var selected = new Set(); // node ids + var selectedEdges = new Set(); + var editing = null; // the text node being edited, see startEdit + var cam = { x: 0, y: 0, z: 1 }; + + // === Helpers === + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function newId() { + var a = new Uint8Array(8); + crypto.getRandomValues(a); + return Array.prototype.map.call(a, function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''); + } + + function snap(v) { return Math.round(v / GRID) * GRID; } + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + + function reindex() { + byId = {}; + doc.nodes.forEach(function (n) { byId[n.id] = n; }); + } + + // contentKey covers everything that changes a node's rendered element + // other than geometry and color. + function contentKey(n) { + return JSON.stringify([n.type, n.text, n.file, n.subpath, n.url, n.label, n.background, n.backgroundStyle]); + } + + function fromHTML(html) { + var t = document.createElement('template'); + t.innerHTML = html.trim(); + return t.content.firstElementChild; + } + + function setColor(target, color, isEl) { + if (/^[1-6]$/.test(color || '')) { + target.setAttribute('data-color', color); + target.style.removeProperty(isEl ? '--node-color' : '--edge-color'); + } else if (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color || '')) { + target.removeAttribute('data-color'); + target.style.setProperty(isEl ? '--node-color' : '--edge-color', color); + } else { + target.removeAttribute('data-color'); + target.style.removeProperty(isEl ? '--node-color' : '--edge-color'); + } + } + + function place(el, n) { + el.style.left = n.x + 'px'; + el.style.top = n.y + 'px'; + el.style.width = n.width + 'px'; + el.style.height = n.height + 'px'; + setColor(el, n.color, true); + el.classList.toggle('is-selected', selected.has(n.id)); + } + + // decorate adds the editing handles to a node element. + function decorate(el) { + if (readOnly) return el; + SIDES.forEach(function (side) { + var h = document.createElement('span'); + h.className = 'canvas-handle'; + h.dataset.side = side; + el.appendChild(h); + }); + var r = document.createElement('span'); + r.className = 'canvas-resize'; + el.appendChild(r); + return el; + } + + function modalOpen() { + return !!document.querySelector('.modal-backdrop'); + } + + // === Camera === + + var camKey = 'canvas-cam:' + baseURL; + + function applyCam() { + world.style.transform = 'translate(' + cam.x + 'px,' + cam.y + 'px) scale(' + cam.z + ')'; + world.style.setProperty('--z', cam.z); + var g = 20 * cam.z; + viewport.style.backgroundSize = g + 'px ' + g + 'px'; + viewport.style.backgroundPosition = cam.x + 'px ' + cam.y + 'px'; + viewport.classList.toggle('is-far', cam.z < 0.4); + if (zoomEl) zoomEl.textContent = Math.round(cam.z * 100) + '%'; + try { sessionStorage.setItem(camKey, JSON.stringify(cam)); } catch (e) {} + } + + function viewportPoint(clientX, clientY) { + var r = viewport.getBoundingClientRect(); + return { x: clientX - r.left, y: clientY - r.top }; + } + + function toWorld(clientX, clientY) { + var p = viewportPoint(clientX, clientY); + return { x: (p.x - cam.x) / cam.z, y: (p.y - cam.y) / cam.z }; + } + + function zoomAt(px, py, z) { + z = clamp(z, 0.1, 4); + cam.x = px - (px - cam.x) * z / cam.z; + cam.y = py - (py - cam.y) * z / cam.z; + cam.z = z; + applyCam(); + } + + function zoomCenter(factor) { + zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, cam.z * factor); + } + + function bounds(nodes) { + if (!nodes.length) return null; + var b = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }; + nodes.forEach(function (n) { + b.x1 = Math.min(b.x1, n.x); + b.y1 = Math.min(b.y1, n.y); + b.x2 = Math.max(b.x2, n.x + n.width); + b.y2 = Math.max(b.y2, n.y + n.height); + }); + return b; + } + + function fit() { + var w = viewport.clientWidth, h = viewport.clientHeight; + var b = bounds(doc.nodes); + if (!b) { + cam = { x: w / 2, y: h / 2, z: 1 }; + } else { + var pad = 40; + var z = clamp(Math.min((w - 2 * pad) / (b.x2 - b.x1), (h - 2 * pad) / (b.y2 - b.y1), 1), 0.1, 1); + cam = { + x: (w - (b.x2 - b.x1) * z) / 2 - b.x1 * z, + y: (h - (b.y2 - b.y1) * z) / 2 - b.y1 * z, + z: z, + }; + } + applyCam(); + } + + // The point new nodes go to: the middle of the view. + function viewCenter() { + return { + x: (viewport.clientWidth / 2 - cam.x) / cam.z, + y: (viewport.clientHeight / 2 - cam.y) / cam.z, + }; + } + + // === Rendering === + + var pending = {}; // content key → in-flight render + + // renderNode fetches the element for n's current content and swaps it in + // once it arrives, unless the node changed again in the meantime. + function renderNode(n) { + var key = contentKey(n); + if (pending[key]) return pending[key]; + var p = fetch(baseURL + '?render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(n), + }).then(function (res) { + if (!res.ok) throw new Error('render failed (' + res.status + ')'); + return res.text(); + }).then(function (html) { + htmlCache[key] = html; + var cur = byId[n.id]; + if (cur && contentKey(cur) === key && elKey[n.id] !== key && !(editing && editing.id === n.id)) { + swapElement(cur, key); + if (cur.type === 'text') growToFit(cur); + renderEdges(); + } + }).catch(function (err) { + setStatus('error', err.message); + }).then(function () { + delete pending[key]; + }); + pending[key] = p; + return p; + } + + function swapElement(n, key) { + var el = decorate(fromHTML(htmlCache[key])); + var old = els[n.id]; + if (old) old.replaceWith(el); + else nodesEl.appendChild(el); + els[n.id] = el; + elKey[n.id] = key; + place(el, n); + } + + // growToFit makes a text node tall enough for its content. It never + // shrinks a node: the size is the user's. + function growToFit(n) { + var el = els[n.id]; + var body = el && el.querySelector('.canvas-node-body'); + if (!body) return; + var extra = body.scrollHeight - body.clientHeight; + if (extra > 1) { + n.height = snap(n.height + extra + GRID / 2); + place(el, n); + scheduleSave(); + } + } + + // syncDOM brings the node elements in line with doc.nodes: creates, + // removes, reorders (array order is z-order), re-renders changed content. + function syncDOM() { + reindex(); + var keep = {}; + doc.nodes.forEach(function (n, i) { + keep[n.id] = true; + var key = contentKey(n); + var el = els[n.id]; + if (elKey[n.id] !== key && !(editing && editing.id === n.id)) { + if (htmlCache[key] !== undefined) { + swapElement(n, key); + el = els[n.id]; + } else { + if (!el) { + // A placeholder until the server's element arrives. + el = decorate(fromHTML('
')); + els[n.id] = el; + elKey[n.id] = null; + } + renderNode(n); + } + } + place(el, n); + if (nodesEl.children[i] !== el) nodesEl.insertBefore(el, nodesEl.children[i] || null); + }); + Object.keys(els).forEach(function (id) { + if (keep[id]) return; + els[id].remove(); + delete els[id]; + delete elKey[id]; + selected.delete(id); + }); + selectedEdges.forEach(function (id) { + if (!doc.edges.some(function (e) { return e.id === id; })) selectedEdges.delete(id); + }); + if (hintEl) hintEl.hidden = doc.nodes.length > 0; + renderEdges(); + } + + // --- Edges --- + + function center(n) { return { x: n.x + n.width / 2, y: n.y + n.height / 2 }; } + + function anchor(n, side) { + switch (side) { + case 'top': return { x: n.x + n.width / 2, y: n.y }; + case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height }; + case 'left': return { x: n.x, y: n.y + n.height / 2 }; + default: return { x: n.x + n.width, y: n.y + n.height / 2 }; + } + } + + // sideToward picks the side of n facing point p. + function sideToward(n, p) { + var c = center(n); + var dx = (p.x - c.x) / Math.max(n.width, 1), dy = (p.y - c.y) / Math.max(n.height, 1); + if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left'; + return dy > 0 ? 'bottom' : 'top'; + } + + // curve returns the bezier of an edge from p1 (leaving side s1) to p2 + // (entering side s2; null for a free end like the pointer). + function curve(p1, s1, p2, s2) { + var d = Math.hypot(p2.x - p1.x, p2.y - p1.y); + var off = clamp(d / 2, 30, 150); + var n1 = NORMALS[s1], n2 = s2 ? NORMALS[s2] : [0, 0]; + return { + p1: p1, + c1: { x: p1.x + n1[0] * off, y: p1.y + n1[1] * off }, + c2: { x: p2.x + n2[0] * off, y: p2.y + n2[1] * off }, + p2: p2, + }; + } + + function curvePath(c) { + return 'M' + c.p1.x + ' ' + c.p1.y + 'C' + c.c1.x + ' ' + c.c1.y + ' ' + c.c2.x + ' ' + c.c2.y + ' ' + c.p2.x + ' ' + c.p2.y; + } + + // arrow draws an arrowhead with its tip at p, pointing from control + // point c toward p. + function arrow(p, c) { + var dx = p.x - c.x, dy = p.y - c.y; + var len = Math.hypot(dx, dy) || 1; + dx /= len; dy /= len; + var s = 12, w = 6; + var bx = p.x - dx * s, by = p.y - dy * s; + return 'M' + p.x + ' ' + p.y + 'L' + (bx - dy * w) + ' ' + (by + dx * w) + 'L' + (bx + dy * w) + ' ' + (by - dx * w) + 'Z'; + } + + function edgeGeometry(e) { + var a = byId[e.fromNode], b = byId[e.toNode]; + if (!a || !b) return null; + var s1 = e.fromSide || sideToward(a, center(b)); + var s2 = e.toSide || sideToward(b, center(a)); + return curve(anchor(a, s1), s1, anchor(b, s2), s2); + } + + var draft = null; // the connection being dragged: { from, side, to: {x,y} } + + function renderEdges() { + var parts = []; + var labels = []; + var box = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }; + function grow(p) { + box.x1 = Math.min(box.x1, p.x); box.y1 = Math.min(box.y1, p.y); + box.x2 = Math.max(box.x2, p.x); box.y2 = Math.max(box.y2, p.y); + } + doc.edges.forEach(function (e) { + var c = edgeGeometry(e); + if (!c) return; + [c.p1, c.c1, c.c2, c.p2].forEach(grow); + var d = curvePath(c); + var preset = /^[1-6]$/.test(e.color || '') ? ' data-color="' + e.color + '"' : ''; + var hex = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(e.color || '') ? '--edge-color:' + e.color + ';' : ''; + var g = '' + + ''; + if ((e.toEnd || 'arrow') === 'arrow') g += ''; + if ((e.fromEnd || 'none') === 'arrow') g += ''; + parts.push(g + ''); + if (e.label) { + var mx = (c.p1.x + 3 * c.c1.x + 3 * c.c2.x + c.p2.x) / 8; + var my = (c.p1.y + 3 * c.c1.y + 3 * c.c2.y + c.p2.y) / 8; + labels.push('
' + esc(e.label) + '
'); + } + }); + if (draft) { + var from = byId[draft.from]; + var c = curve(anchor(from, draft.side), draft.side, draft.to, null); + [c.p1, c.c1, c.c2, c.p2].forEach(grow); + parts.push(''); + } + if (box.x1 === Infinity) { + edgesSvg.style.width = edgesSvg.style.height = '0'; + edgesSvg.innerHTML = ''; + } else { + var m = 40; + var x = box.x1 - m, y = box.y1 - m; + edgesSvg.style.left = x + 'px'; + edgesSvg.style.top = y + 'px'; + edgesSvg.style.width = (box.x2 - box.x1 + 2 * m) + 'px'; + edgesSvg.style.height = (box.y2 - box.y1 + 2 * m) + 'px'; + edgesSvg.innerHTML = '' + parts.join('') + ''; + } + labelsEl.innerHTML = labels.join(''); + } + + // === Selection === + + function select(ids, edgeIds) { + selected = new Set(ids || []); + selectedEdges = new Set(edgeIds || []); + Object.keys(els).forEach(function (id) { + els[id].classList.toggle('is-selected', selected.has(id)); + }); + renderEdges(); + } + + function toggleSelected(id) { + var s = new Set(selected); + if (s.has(id)) s.delete(id); else s.add(id); + select(Array.from(s), Array.from(selectedEdges)); + } + + // === Undo, mutations === + + var undoStack = [], redoStack = []; + + function snapshot() { + return JSON.stringify({ nodes: doc.nodes, edges: doc.edges }); + } + + function pushUndo(before) { + if (before === snapshot()) return; + undoStack.push(before); + if (undoStack.length > 200) undoStack.shift(); + redoStack = []; + } + + // mutate runs fn as one undoable change. + function mutate(fn) { + if (readOnly) return; + var before = snapshot(); + fn(); + pushUndo(before); + syncDOM(); + scheduleSave(); + } + + function restore(s) { + var d = JSON.parse(s); + doc.nodes = d.nodes; + doc.edges = d.edges; + syncDOM(); + select(Array.from(selected).filter(function (id) { return byId[id]; })); + scheduleSave(); + } + + function undo() { + commitEdit(); + if (!undoStack.length) return; + redoStack.push(snapshot()); + restore(undoStack.pop()); + } + + function redo() { + commitEdit(); + if (!redoStack.length) return; + undoStack.push(snapshot()); + restore(redoStack.pop()); + } + + // === Saving === + // Every change saves itself shortly after. A save carries the version it + // is based on; the server refuses it (409) when the file changed + // elsewhere, and from then on nothing is saved until a reload. + + var saveTimer = null, saving = false, dirty = false, failed = false, conflict = false; + + function setStatus(state, msg) { + if (!statusEl) return; + var text = { + saved: 'Saved', + unsaved: 'Unsaved', + saving: 'Saving…', + error: 'Not saved: ' + (msg || 'error'), + conflict: 'Changed elsewhere — reload to continue', + }[state]; + statusEl.textContent = text; + statusEl.title = msg || ''; + statusEl.classList.toggle('is-error', state === 'error' || state === 'conflict'); + } + + function scheduleSave() { + if (readOnly || conflict) return; + dirty = true; + if (!saving) setStatus('unsaved'); + clearTimeout(saveTimer); + saveTimer = setTimeout(save, 800); + } + + function save() { + clearTimeout(saveTimer); + saveTimer = null; + if (saving || !dirty || conflict) return; + saving = true; + dirty = false; + setStatus('saving'); + var retry = false; + fetch(baseURL + '?save', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Canvas-Version': version }, + body: JSON.stringify(doc), + }).then(function (res) { + if (res.ok) { + version = res.headers.get('X-Canvas-Version') || version; + failed = false; + return; + } + return res.text().then(function (msg) { + failed = true; + dirty = true; + if (res.status === 409) { + conflict = true; + setStatus('conflict', msg); + } else { + // Server errors may pass; a refused document would only + // be refused again, so that waits for the next change. + retry = res.status >= 500; + setStatus('error', msg.trim() || String(res.status)); + } + }); + }).catch(function () { + failed = true; + dirty = true; + retry = true; + setStatus('error', 'network error'); + }).then(function () { + saving = false; + if (conflict) return; + if (retry) { + saveTimer = setTimeout(save, 5000); + } else if (dirty && !failed) { + save(); + } else if (!dirty) { + setStatus('saved'); + } + }); + } + + // Leaving the page: commit an open editor and send what is unsaved with + // keepalive, which outlives the page (up to 64 KB). Only a save that cannot + // go out that way asks the user to stay. + window.addEventListener('beforeunload', function (e) { + commitEdit(); + if (!dirty || conflict) return; + var body = JSON.stringify(doc); + if (!saving && body.length < 60000) { + clearTimeout(saveTimer); + dirty = false; + saving = true; + fetch(baseURL + '?save', { + method: 'POST', + keepalive: true, + headers: { 'Content-Type': 'application/json', 'X-Canvas-Version': version }, + body: body, + }); + return; + } + e.preventDefault(); + e.returnValue = ''; + }); + + // A tab going to the background may never come back (mobile). + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden' && dirty && !saving) save(); + }); + + // === Editing nodes === + + // startEdit opens the markdown editor (editor/core.js) inside a text node. + function startEdit(id, isNew) { + var n = byId[id]; + if (readOnly || !n || n.type !== 'text' || !window.MarkdownEditor) return; + if (editing) { + if (editing.id === id) return; + commitEdit(); + } + select([id]); + var el = els[id]; + var body = el.querySelector('.canvas-node-body'); + var mount = document.createElement('div'); + mount.className = 'canvas-node-editor'; + body.hidden = true; + el.appendChild(mount); + el.classList.add('is-editing'); + nodeToolbar.hidden = false; + var commitKey = function () { + if (document.querySelector('.cm-tooltip-autocomplete')) return false; + commitEdit(); + return true; + }; + editing = { + id: id, + el: el, + body: body, + mount: mount, + isNew: !!isNew, + before: snapshot(), + editor: MarkdownEditor.create(mount, { + doc: n.text || '', + keymap: [{ key: 'Escape', run: commitKey }, { key: 'Mod-Enter', run: commitKey }], + }), + }; + } + + function commitEdit() { + if (!editing) return; + var e = editing; + editing = null; + var text = e.editor.getValue(); + e.editor.destroy(); + e.mount.remove(); + e.body.hidden = false; + e.el.classList.remove('is-editing'); + nodeToolbar.hidden = true; + var n = byId[e.id]; + if (!n) return; + if (e.isNew && !text.trim()) { + // An empty new note was a misclick: drop it without a trace. + doc.nodes = doc.nodes.filter(function (x) { return x.id !== n.id; }); + doc.edges = doc.edges.filter(function (x) { return x.fromNode !== n.id && x.toNode !== n.id; }); + syncDOM(); + if (e.before !== snapshot()) pushUndo(e.before); + scheduleSave(); + } else if (text !== (n.text || '') || e.isNew) { + n.text = text; + pushUndo(e.before); + syncDOM(); + scheduleSave(); + } + viewport.focus({ preventScroll: true }); + } + + function promptText(title, value, placeholder, onDone) { + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'input'; + input.placeholder = placeholder || ''; + input.value = value || ''; + var handle = openModal({ + title: title, + body: input, + confirm: { + label: 'OK', + onConfirm: function () { + handle.close(); + onDone(input.value.trim()); + } + } + }); + } + + // editNode is the double-click / Enter action for every node type. + function editNode(id) { + var n = byId[id]; + if (!n || readOnly) return; + switch (n.type) { + case 'text': + startEdit(id); + break; + case 'group': + promptText('Group label', n.label, 'Label (optional)', function (v) { + mutate(function () { if (v) n.label = v; else delete n.label; }); + }); + break; + case 'link': + promptText('Link', n.url, 'https://…', function (v) { + if (v) mutate(function () { n.url = v; }); + }); + break; + case 'file': + pickFile(function (rel) { + mutate(function () { n.file = rel; delete n.subpath; }); + }); + break; + } + } + + function editEdgeLabel(id) { + var e = doc.edges.find(function (x) { return x.id === id; }); + if (!e || readOnly) return; + promptText('Connection label', e.label, 'Label (optional)', function (v) { + mutate(function () { if (v) e.label = v; else delete e.label; }); + }); + } + + function pageFolder() { + return decodeURIComponent(baseURL.slice(0, baseURL.lastIndexOf('/'))) || '/'; + } + + function pickFile(onPick) { + openTreePicker({ + title: 'Pick a file or page', + mode: 'any', + initialPath: pageFolder(), + allowRoot: false, + confirmLabel: 'ADD', + onSelect: function (absPath) { onPick(absPath.replace(/^\/+/, '')); } + }); + } + + // === Commands === + + function addNode(n, at) { + n.id = newId(); + var c = at || viewCenter(); + n.x = snap(c.x - n.width / 2); + n.y = snap(c.y - n.height / 2); + mutate(function () { + if (n.type === 'group') doc.nodes.unshift(n); else doc.nodes.push(n); + }); + select([n.id]); + return n; + } + + // addText adds a note and opens its editor. Creating and writing it is + // one undo step, and a note left empty disappears (commitEdit). + function addText(at) { + commitEdit(); + var before = snapshot(); + var n = addNode({ type: 'text', text: '', width: 260, height: 80 }, at); + undoStack.pop(); + startEdit(n.id, true); + editing.before = before; + return n; + } + + function addFile() { + pickFile(function (rel) { + var image = /\.(png|jpe?g|gif|webp|svg)$/i.test(rel); + var card = !image && !/\.md$/i.test(rel) && /\.[^\/]+$/.test(rel); + addNode({ type: 'file', file: rel, width: card ? 300 : 400, height: card ? 60 : image ? 300 : 400 }); + }); + } + + function addLink() { + promptText('Add link', '', 'https://…', function (v) { + if (v) addNode({ type: 'link', url: v, width: 320, height: 70 }); + }); + } + + // addGroup puts a group around the selection, or an empty one in view. + function addGroup() { + var nodes = Array.from(selected).map(function (id) { return byId[id]; }).filter(Boolean); + var b = bounds(nodes); + var n = { type: 'group', width: 400, height: 300 }; + var at; + if (b) { + var pad = 30; + n.width = b.x2 - b.x1 + 2 * pad; + n.height = b.y2 - b.y1 + 2 * pad; + at = { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2 }; + } + n = addNode(n, at); + editNode(n.id); + } + + function deleteSelection() { + if (!selected.size && !selectedEdges.size) return; + mutate(function () { + doc.nodes = doc.nodes.filter(function (n) { return !selected.has(n.id); }); + doc.edges = doc.edges.filter(function (e) { + return !selectedEdges.has(e.id) && !selected.has(e.fromNode) && !selected.has(e.toNode); + }); + }); + select([]); + } + + function duplicateSelection() { + if (!selected.size) return; + var map = {}; + var copies = []; + mutate(function () { + doc.nodes.filter(function (n) { return selected.has(n.id); }).forEach(function (n) { + var c = JSON.parse(JSON.stringify(n)); + c.id = newId(); + c.x += 3 * GRID; + c.y += 3 * GRID; + map[n.id] = c.id; + copies.push(c); + }); + doc.nodes = doc.nodes.concat(copies); + doc.edges.filter(function (e) { return map[e.fromNode] && map[e.toNode]; }).forEach(function (e) { + var c = JSON.parse(JSON.stringify(e)); + c.id = newId(); + c.fromNode = map[e.fromNode]; + c.toNode = map[e.toNode]; + doc.edges.push(c); + }); + }); + select(copies.map(function (c) { return c.id; })); + } + + function setSelectionColor(color) { + if (!selected.size && !selectedEdges.size) return; + function paint(x) { + if (color) x.color = color; else delete x.color; + } + mutate(function () { + doc.nodes.forEach(function (n) { if (selected.has(n.id)) paint(n); }); + doc.edges.forEach(function (e) { if (selectedEdges.has(e.id)) paint(e); }); + }); + } + + function setEnds(value) { + if (!selectedEdges.size) return; + mutate(function () { + doc.edges.forEach(function (e) { + if (!selectedEdges.has(e.id)) return; + if (value === 'reverse') { + var t = [e.fromNode, e.fromSide, e.fromEnd]; + e.fromNode = e.toNode; e.fromSide = e.toSide; e.fromEnd = e.toEnd; + e.toNode = t[0]; e.toSide = t[1]; e.toEnd = t[2]; + ['fromSide', 'fromEnd', 'toSide', 'toEnd'].forEach(function (k) { if (e[k] === undefined) delete e[k]; }); + return; + } + var v = value.split(' '); + if (v[0] === 'none') delete e.fromEnd; else e.fromEnd = v[0]; + if (v[1] === 'arrow') delete e.toEnd; else e.toEnd = v[1]; + }); + }); + } + + function nudge(dx, dy) { + if (!selected.size) return; + mutate(function () { + movedSet(Array.from(selected)).forEach(function (id) { + byId[id].x += dx; + byId[id].y += dy; + }); + }); + } + + // movedSet is what moves with the given nodes: groups take the nodes + // inside them along. + function movedSet(ids) { + var out = new Set(ids); + ids.forEach(function (id) { + var g = byId[id]; + if (!g || g.type !== 'group') return; + doc.nodes.forEach(function (n) { + if (n.id !== g.id && n.x >= g.x && n.y >= g.y && + n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height) out.add(n.id); + }); + }); + return Array.from(out); + } + + // === Pointer input === + + var gesture = null; + var pointers = new Map(); + var spaceDown = false; + var suppressClick = false; + + function startPinch() { + var pts = Array.from(pointers.values()); + var a = viewportPoint(pts[0].x, pts[0].y), b = viewportPoint(pts[1].x, pts[1].y); + var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + gesture = { + type: 'pinch', + dist: Math.hypot(a.x - b.x, a.y - b.y) || 1, + world: { x: (mid.x - cam.x) / cam.z, y: (mid.y - cam.y) / cam.z }, + z: cam.z, + }; + } + + viewport.addEventListener('pointerdown', function (e) { + if (editing && editing.el.contains(e.target)) return; + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pointers.size === 2) { + if (gesture && gesture.before) restore(gesture.before); + startPinch(); + return; + } + if (pointers.size > 2) return; + commitEdit(); + viewport.focus({ preventScroll: true }); + + var base = { startX: e.clientX, startY: e.clientY, moved: false }; + var nodeEl = e.target.closest('.canvas-node'); + var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label'); + var additive = e.shiftKey || e.ctrlKey || e.metaKey; + + if (e.button === 1 || (e.button === 0 && spaceDown)) { + e.preventDefault(); + gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) }); + return; + } + if (e.button !== 0) return; + + if (nodeEl && !readOnly && e.target.closest('.canvas-handle')) { + e.preventDefault(); + gesture = Object.assign(base, { type: 'connect', from: nodeEl.dataset.id, side: e.target.dataset.side }); + return; + } + if (nodeEl && !readOnly && e.target.closest('.canvas-resize')) { + e.preventDefault(); + var rn = byId[nodeEl.dataset.id]; + gesture = Object.assign(base, { type: 'resize', id: rn.id, w: rn.width, h: rn.height, before: snapshot() }); + return; + } + if (nodeEl) { + var id = nodeEl.dataset.id; + var wasSelected = selected.has(id); + if (additive) toggleSelected(id); + else if (!wasSelected) select([id]); + if (readOnly || (additive && wasSelected)) return; + var ids = movedSet(Array.from(selected)); + var origins = {}; + ids.forEach(function (i) { origins[i] = { x: byId[i].x, y: byId[i].y }; }); + gesture = Object.assign(base, { type: 'move', id: id, ids: ids, origins: origins, additive: additive, before: snapshot() }); + return; + } + if (edgeEl) { + var eid = edgeEl.dataset.id; + if (additive) { + var s = new Set(selectedEdges); + if (s.has(eid)) s.delete(eid); else s.add(eid); + select(Array.from(selected), Array.from(s)); + } else { + select([], [eid]); + } + return; + } + // The background. + if (e.shiftKey) { + var box = document.createElement('div'); + box.className = 'canvas-marquee'; + viewport.appendChild(box); + gesture = Object.assign(base, { type: 'marquee', box: box, keep: Array.from(selected) }); + } else { + if (!additive) select([]); + gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) }); + } + }); + + window.addEventListener('pointermove', function (e) { + if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (!gesture) return; + if (gesture.type === 'pinch') { + if (pointers.size < 2) return; + var pts = Array.from(pointers.values()); + var a = viewportPoint(pts[0].x, pts[0].y), b = viewportPoint(pts[1].x, pts[1].y); + var z = clamp(gesture.z * Math.hypot(a.x - b.x, a.y - b.y) / gesture.dist, 0.1, 4); + cam.z = z; + cam.x = (a.x + b.x) / 2 - gesture.world.x * z; + cam.y = (a.y + b.y) / 2 - gesture.world.y * z; + applyCam(); + return; + } + var dxs = e.clientX - gesture.startX, dys = e.clientY - gesture.startY; + if (!gesture.moved) { + if (Math.hypot(dxs, dys) < 4) return; + gesture.moved = true; + if (gesture.type === 'pan') viewport.classList.add('is-panning'); + } + var dx = dxs / cam.z, dy = dys / cam.z; + switch (gesture.type) { + case 'pan': + cam.x = gesture.cam.x + dxs; + cam.y = gesture.cam.y + dys; + applyCam(); + break; + case 'move': + gesture.ids.forEach(function (id) { + var n = byId[id], o = gesture.origins[id]; + if (!n) return; + n.x = e.altKey ? Math.round(o.x + dx) : snap(o.x + dx); + n.y = e.altKey ? Math.round(o.y + dy) : snap(o.y + dy); + place(els[id], n); + }); + renderEdges(); + break; + case 'resize': + var n = byId[gesture.id]; + n.width = Math.max(MIN_W, e.altKey ? Math.round(gesture.w + dx) : snap(gesture.w + dx)); + n.height = Math.max(MIN_H, e.altKey ? Math.round(gesture.h + dy) : snap(gesture.h + dy)); + place(els[n.id], n); + renderEdges(); + break; + case 'connect': + draft = { from: gesture.from, side: gesture.side, to: toWorld(e.clientX, e.clientY) }; + renderEdges(); + break; + case 'marquee': + var p0 = viewportPoint(gesture.startX, gesture.startY), p1 = viewportPoint(e.clientX, e.clientY); + var r = { x1: Math.min(p0.x, p1.x), y1: Math.min(p0.y, p1.y), x2: Math.max(p0.x, p1.x), y2: Math.max(p0.y, p1.y) }; + Object.assign(gesture.box.style, { left: r.x1 + 'px', top: r.y1 + 'px', width: (r.x2 - r.x1) + 'px', height: (r.y2 - r.y1) + 'px' }); + var w1 = { x: (r.x1 - cam.x) / cam.z, y: (r.y1 - cam.y) / cam.z }; + var w2 = { x: (r.x2 - cam.x) / cam.z, y: (r.y2 - cam.y) / cam.z }; + var hit = doc.nodes.filter(function (n) { + return n.x < w2.x && n.x + n.width > w1.x && n.y < w2.y && n.y + n.height > w1.y; + }).map(function (n) { return n.id; }); + select(gesture.keep.concat(hit)); + break; + } + }); + + function endPointer(e) { + pointers.delete(e.pointerId); + var g = gesture; + if (!g) return; + if (g.type === 'pinch') { + if (pointers.size < 2) gesture = null; + return; + } + gesture = null; + viewport.classList.remove('is-panning'); + if (g.moved) suppressClick = true; + switch (g.type) { + case 'move': + case 'resize': + if (g.moved) { + pushUndo(g.before); + scheduleSave(); + } else if (g.type === 'move' && !g.additive && e.type === 'pointerup') { + select([g.id]); + } + break; + case 'connect': + draft = null; + if (!g.moved || e.type !== 'pointerup') { + renderEdges(); + break; + } + var target = document.elementFromPoint(e.clientX, e.clientY); + var toEl = target && target.closest('.canvas-node'); + var p = toWorld(e.clientX, e.clientY); + var edge = { id: newId(), fromNode: g.from, fromSide: g.side }; + if (toEl && toEl.dataset.id !== g.from && byId[toEl.dataset.id]) { + var to = byId[toEl.dataset.id]; + edge.toNode = to.id; + edge.toSide = target.classList.contains('canvas-handle') ? target.dataset.side : sideToward(to, p); + mutate(function () { doc.edges.push(edge); }); + select([], [edge.id]); + } else if (!toEl) { + // Dropped on empty space: a new note there, connected. + var before = snapshot(); + var n = { id: newId(), type: 'text', text: '', width: 260, height: 80 }; + // Its side facing back sits at the drop point. + var side = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[g.side]; + var dir = NORMALS[side]; + n.x = snap(p.x - n.width / 2 - dir[0] * n.width / 2); + n.y = snap(p.y - n.height / 2 - dir[1] * n.height / 2); + edge.toNode = n.id; + edge.toSide = side; + doc.nodes.push(n); + doc.edges.push(edge); + syncDOM(); + startEdit(n.id, true); + editing.before = before; + scheduleSave(); + } else { + renderEdges(); + } + break; + case 'marquee': + g.box.remove(); + break; + } + } + window.addEventListener('pointerup', endPointer); + window.addEventListener('pointercancel', endPointer); + + // A drag that started on a link or checkbox must not also click it. + window.addEventListener('click', function (e) { + if (!suppressClick) return; + suppressClick = false; + if (viewport.contains(e.target)) { + e.preventDefault(); + e.stopPropagation(); + } + }, true); + viewport.addEventListener('pointerdown', function () { suppressClick = false; }, true); + + // Native drag of images and links would swallow the pointer moves. + viewport.addEventListener('dragstart', function (e) { e.preventDefault(); }); + + viewport.addEventListener('wheel', function (e) { + if (editing && editing.el.contains(e.target)) return; + var body = e.target.closest('.canvas-node-body'); + if (body && !e.ctrlKey && body.scrollHeight > body.clientHeight + 1) return; + e.preventDefault(); + var dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1); + if (e.shiftKey) { + cam.x -= dy; + applyCam(); + return; + } + var p = viewportPoint(e.clientX, e.clientY); + zoomAt(p.x, p.y, cam.z * Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0015))); + }, { passive: false }); + + viewport.addEventListener('dblclick', function (e) { + if (readOnly || (editing && editing.el.contains(e.target))) return; + var nodeEl = e.target.closest('.canvas-node'); + var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label'); + if (nodeEl) { + if (e.target.closest('a, input, video, audio')) return; + e.preventDefault(); + editNode(nodeEl.dataset.id); + } else if (edgeEl) { + editEdgeLabel(edgeEl.dataset.id); + } else { + addText(toWorld(e.clientX, e.clientY)); + } + }); + + // Task checkboxes in notes flip their line in the note's text. + viewport.addEventListener('change', function (e) { + var box = e.target; + if (!box.matches || !box.matches('input.task-checkbox[data-task]')) return; + var nodeEl = box.closest('.canvas-node'); + var n = nodeEl && byId[nodeEl.dataset.id]; + var text = n && flipTask(n.text || '', +box.dataset.task, box.checked); + if (text === null || text === undefined || readOnly) { + box.checked = !box.checked; + return; + } + mutate(function () { n.text = text; }); + }); + + // flipTask mirrors tasks.go flipTaskLine: the Nth task line outside + // fenced code gets its box set. + function flipTask(text, idx, checked) { + var lines = text.split('\n'); + var inFence = false, count = 0; + for (var i = 0; i < lines.length; i++) { + var t = lines[i].replace(/^[ \t]+/, ''); + if (t.indexOf('```') === 0 || t.indexOf('~~~') === 0) { + inFence = !inFence; + continue; + } + if (inFence) continue; + var m = /^(\s*[-*+]\s+)\[([ xX])\]/.exec(lines[i]); + if (!m) continue; + if (count === idx) { + lines[i] = m[1] + (checked ? '[x]' : '[ ]') + lines[i].slice(m[0].length); + return lines.join('\n'); + } + count++; + } + return null; + } + + // === Keyboard === + + function typingInField(e) { + var t = e.target; + return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable); + } + + // Capture phase, so the page shortcuts (global-shortcuts.js) don't see + // the ones that mean something else here: E edits the selected node, M + // (move page) does not apply to a canvas. + window.addEventListener('keydown', function (e) { + if (!e.altKey || !e.shiftKey || modalOpen()) return; + if (e.key === 'E') { + e.preventDefault(); + e.stopPropagation(); + if (!editing && selected.size === 1) editNode(Array.from(selected)[0]); + } else if (e.key === 'M') { + e.stopPropagation(); + } + }, true); + + document.addEventListener('keydown', function (e) { + if (e.key === ' ' && !typingInField(e)) spaceDown = true; + if (editing || modalOpen() || typingInField(e) || readOnly) return; + var mod = e.ctrlKey || e.metaKey; + var step = e.shiftKey ? 1 : GRID; + switch (true) { + case e.key === 'Delete' || e.key === 'Backspace': + e.preventDefault(); + deleteSelection(); + break; + case mod && !e.shiftKey && e.key.toLowerCase() === 'z': + e.preventDefault(); + undo(); + break; + case mod && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z')): + e.preventDefault(); + redo(); + break; + case mod && e.key.toLowerCase() === 'd': + e.preventDefault(); + duplicateSelection(); + break; + case mod && e.key.toLowerCase() === 'a': + e.preventDefault(); + select(doc.nodes.map(function (n) { return n.id; })); + break; + case e.key === 'Enter' && selected.size === 1: + e.preventDefault(); + editNode(Array.from(selected)[0]); + break; + case e.key === 'Escape': + select([]); + break; + case e.key === 'ArrowLeft': e.preventDefault(); nudge(-step, 0); break; + case e.key === 'ArrowRight': e.preventDefault(); nudge(step, 0); break; + case e.key === 'ArrowUp': e.preventDefault(); nudge(0, -step); break; + case e.key === 'ArrowDown': e.preventDefault(); nudge(0, step); break; + } + }); + document.addEventListener('keyup', function (e) { + if (e.key === ' ') spaceDown = false; + }); + + // === Toolbar === + + var commands = { + text: function () { addText(); }, + file: addFile, + link: addLink, + group: addGroup, + color: function (btn) { setSelectionColor(btn.dataset.value); }, + ends: function (btn) { setEnds(btn.dataset.value); }, + undo: undo, + redo: redo, + 'delete': deleteSelection, + zoomin: function () { zoomCenter(1.25); }, + zoomout: function () { zoomCenter(0.8); }, + zoomreset: function () { zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); }, + fit: fit, + }; + + app.querySelectorAll('[data-canvas]').forEach(function (btn) { + btn.addEventListener('click', function () { + commitEdit(); + commands[btn.dataset.canvas](btn); + }); + }); + app.querySelectorAll('.canvas-bar .dropdown-toggle').forEach(wireDropdown); + var colorInput = app.querySelector('.canvas-color-input'); + if (colorInput) colorInput.addEventListener('change', function () { setSelectionColor(colorInput.value); }); + + if (window.MarkdownEditor) { + MarkdownEditor.bind(nodeToolbar, function () { return editing && editing.editor.actions; }); + } + + // === Start === + + reindex(); + nodesEl.querySelectorAll('.canvas-node').forEach(function (el) { + var n = byId[el.dataset.id]; + if (!n) return; + els[n.id] = el; + elKey[n.id] = contentKey(n); + htmlCache[elKey[n.id]] = el.outerHTML; + decorate(el); + }); + renderEdges(); + var saved = null; + try { saved = JSON.parse(sessionStorage.getItem(camKey)); } catch (e) {} + if (saved && isFinite(saved.x) && isFinite(saved.y) && saved.z > 0) { + cam = saved; + applyCam(); + } else { + fit(); + } + if (!readOnly) setStatus('saved'); + viewport.focus({ preventScroll: true }); +})(); diff --git a/assets/canvas/main.html b/assets/canvas/main.html new file mode 100644 index 0000000..60e8d10 --- /dev/null +++ b/assets/canvas/main.html @@ -0,0 +1,83 @@ +{{/* The canvas view (canvas.go). It fills the whole shell: no folder tree, +no sidebar, no footer. Links are not boosted, so leaving the canvas is a full +navigation and canvas.js can save on pagehide. */}} + +{{define "boost"}} hx-boost="false"{{end}} + +{{define "page-actions"}}
{{template "headerActions" .}}
{{end}} + +{{define "headerActions"}} +{{icon "up"}}PAGE +{{icon "doc"}}DOWNLOAD +{{end}} + +{{define "shell"}} +
+
+ {{.PageURL}}{{.Name}} + {{if not .Error}} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{end}} +
+ {{if .Error}}

This canvas cannot be edited: {{.Error}}

{{end}} +
+ +
+
+ +
+
{{range .Nodes}}{{template "canvas-node" .}}{{end}}
+
+

Double-click to add a note

+
+
+ +
+{{if not .Error}} + + + + + + +{{end}} + +{{end}} diff --git a/assets/canvas/node.html b/assets/canvas/node.html new file mode 100644 index 0000000..6e43b02 --- /dev/null +++ b/assets/canvas/node.html @@ -0,0 +1,20 @@ +{{/* One canvas node element (canvas.go canvasNodeView). Rendered for the +whole canvas on load and for a single node by POST ?render, so the editor +(canvas/canvas.js) never builds node markup itself. Geometry is in canvas +units; canvas.js keeps style and data-color in sync while editing. */}} + +{{define "canvas-node"}}
+{{- if .Label}}
{{if .LabelURL}}{{.Label}}{{else}}{{.Label}}{{end}}
{{end -}} +
{{.Body}}
{{end}} + +{{define "canvas-media"}}{{if eq .Kind "image"}} +{{- else if eq .Kind "video"}} +{{- else}}{{end}}{{end}} + +{{define "canvas-file"}}{{.Icon}}{{.Name}}{{end}} + +{{define "canvas-link"}}{{.Host}}{{.URL}}{{end}} + +{{define "canvas-missing"}}

Missing: {{.}}

{{end}} + +{{define "canvas-group-bg"}}
{{end}} diff --git a/assets/editor/core.js b/assets/editor/core.js new file mode 100644 index 0000000..7345508 --- /dev/null +++ b/assets/editor/core.js @@ -0,0 +1,430 @@ +// core.js — the markdown editor: the CodeMirror setup plus the formatting +// actions behind the toolbar (editor/toolbar.html) and the Alt+Shift +// shortcuts. Shared by the page editor (main.js) and the canvas node editor +// (canvas/canvas.js). +// +// MarkdownEditor.create(mount, opts) → { view, actions, getValue, focus, destroy } +// opts.doc initial text +// opts.keymap extra CM key bindings, ahead of the defaults +// MarkdownEditor.bind(root, getActions) +// wires root's [data-action] buttons, [data-key] shortcuts and toolbar +// dropdowns to whatever getActions() returns (null = no editor open). +window.MarkdownEditor = (function () { + var T = EditorTables; + var D = EditorDates; + var M = EditorMovie; + + // Enter/Tab cell navigation must yield to an open autocomplete popup so those + // keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell). + // The table keymap sits at Prec.highest, so without this guard it would + // swallow the key before the completion keymap ever sees it. Detect the popup + // via its tooltip element rather than exporting completionStatus from the + // vendored bundle (which would force a bundle rebuild). + function completionOpen() { + return !!document.querySelector('.cm-tooltip-autocomplete'); + } + + // pageBase is the folder of the page being edited, slash-terminated: the + // page URL itself, or for a canvas (/Page/Board.canvas) the folder it + // lives in. Relative embed destinations resolve against it. + function pageBase() { + var p = decodeURIComponent(window.location.pathname); + return p.slice(0, p.lastIndexOf('/') + 1); + } + + function create(mount, opts) { + opts = opts || {}; + var doc = opts.doc || ''; + var view; + + // Shift+Enter (new table row below) / Shift+Delete (delete table row) run at + // highest precedence so they win over CM's default newline/forward-delete. + // Returning false (no table at cursor) lets CM fall back to its default. + function tableKey(fn) { + return function (v) { + var result = fn(v.state.doc.toString(), v.state.selection.main.head); + if (!result) return false; + dispatchFullReplace(result); + return true; + }; + } + + function tableNavKey(fn) { + var handler = tableKey(fn); + return function (v) { + if (completionOpen()) return false; + return handler(v); + }; + } + + var tableKeymap = [ + { key: 'Shift-Enter', run: tableKey(T.insertRowBelow) }, + { key: 'Shift-Delete', run: tableKey(T.deleteRow) }, + { key: 'Enter', run: tableNavKey(T.nextRowSameColumn) }, + { key: 'Tab', run: tableNavKey(T.nextCell) }, + { key: 'Shift-Tab', run: tableNavKey(T.prevCell) }, + ]; + + var state = CM.EditorState.create({ + doc: doc, + // Start with the caret at the end of the existing content — editing an + // existing page almost always means appending, and the default (start + // of doc) buries the caret behind the leading heading. + selection: { anchor: doc.length }, + extensions: [ + // The caller's keys go first, ahead of even autocompletion's + // own highest-precedence keys: those swallow an Escape while a + // completion query is still pending, with no popup in sight. + CM.Prec.highest(CM.keymap.of(opts.keymap || [])), + CM.history(), + CM.drawSelection(), + CM.indentOnInput(), + CM.EditorView.lineWrapping, + // Enable native browser spellcheck on the contenteditable surface + // (CM6 leaves it off by default). autocapitalize helps prose entry + // on the Android/mobile path; CM's DOM observer absorbs corrections. + 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, + CM.Prec.highest(CM.keymap.of(tableKeymap)), + CM.keymap.of([].concat( + CM.closeBracketsKeymap, + CM.completionKeymap, + CM.markdownKeymap, + CM.defaultKeymap, + CM.historyKeymap, + [CM.indentWithTab] + )), + ], + }); + + view = new CM.EditorView({ state: state, parent: mount }); + view.focus(); + + // --- CM document helpers --- + + function dispatchFullReplace(result) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: result.text }, + selection: { anchor: result.cursor }, + scrollIntoView: true, + }); + view.focus(); + } + + function insertAtCursor(s) { + var sel = view.state.selection.main; + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: s }, + selection: { anchor: sel.from + s.length }, + scrollIntoView: true, + }); + view.focus(); + } + + function wrap(before, after, placeholder) { + var sel = view.state.selection.main; + var hadSelection = sel.to > sel.from; + var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder; + var insert = before + selected + after; + var anchor, head; + if (hadSelection) { + anchor = head = sel.from + insert.length; + } else { + anchor = sel.from + before.length; + head = anchor + placeholder.length; + } + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: insert }, + selection: { anchor: anchor, head: head }, + scrollIntoView: true, + }); + view.focus(); + } + + function linePrefix(prefix) { + var sel = view.state.selection.main; + var line = view.state.doc.lineAt(sel.from); + view.dispatch({ + changes: { from: line.from, to: line.from, insert: prefix }, + selection: { anchor: sel.from + prefix.length }, + scrollIntoView: true, + }); + view.focus(); + } + + function applyTableOp(fn, arg) { + var text = view.state.doc.toString(); + var pos = view.state.selection.main.head; + var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos); + if (result) dispatchFullReplace(result); + } + + // Adapter passed to movie.js so it reads/writes the CM document instead of a + // textarea (replace() dispatches a transaction; cursor lands after the block). + var movieCtx = { + getValue: function () { return view.state.doc.toString(); }, + replace: function (start, end, text) { + view.dispatch({ + changes: { from: start, to: end, insert: text }, + selection: { anchor: start + text.length }, + scrollIntoView: true, + }); + view.focus(); + }, + }; + + // Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead + // plus an optional display-text field, inserting [[query]] or + // [[query::display]] at the cursor. Picking a suggestion only fills in that + // page's name — the link resolves through search at click time, so a path + // never enters the document. Inline `[[` typing uses the same suggestions + // via wikicomplete.js. + function insertWikilink() { + var sel = view.state.selection.main; + var selectedText = view.state.sliceDoc(sel.from, sel.to).trim(); + + var container = document.createElement('div'); + + var queryWrap = document.createElement('div'); + var queryInput = document.createElement('input'); + queryInput.type = 'text'; + queryInput.className = 'input'; + queryInput.placeholder = 'Search query'; + // A selection is the query, not the label: [[selection]] renders with + // the same visible text anyway, and it seeds the typeahead. + queryInput.value = selectedText; + queryWrap.appendChild(queryInput); + + var displayInput = document.createElement('input'); + displayInput.type = 'text'; + displayInput.className = 'input'; + displayInput.placeholder = 'Display text (optional)'; + + container.appendChild(queryWrap); + container.appendChild(displayInput); + + var handle = openModal({ + title: 'Insert link', + body: container, + confirm: { + label: 'INSERT', + initiallyDisabled: !selectedText, + onConfirm: function () { + var query = queryInput.value.trim(); + if (!query) return; + var display = displayInput.value.trim(); + handle.close(); + insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]'); + } + } + }); + + function updateConfirm() { + handle.setConfirmDisabled(!queryInput.value.trim()); + } + queryInput.addEventListener('input', updateConfirm); + + window.attachSuggestions(queryInput, { + showFooter: false, + container: queryWrap, + onPick: function (r) { + queryInput.value = r.name; + updateConfirm(); + displayInput.focus(); + } + }); + } + + // embedDest turns the absolute wiki path the tree picker hands back into the + // destination written to the document: relative to the page being edited + // when the file lives under it, absolute otherwise. Pages render at a + // slash-terminated URL, so a relative destination resolves against the page + // in the browser. + function embedDest(absPath) { + var base = pageBase(); + return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath; + } + + // embedToken formats a Markdown image. A destination carrying spaces or + // brackets goes in — CommonMark's way of keeping it in one + // piece — and the title slot holds the alignment keyword the renderer reads. + // Right is the renderer's default, so it is left off. + function embedToken(dest, caption, align) { + if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>'; + var title = (align && align !== 'right') ? ' "' + align + '"' : ''; + return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')'; + } + + // Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a + // caption and an alignment. Images render as a floated figure; anything + // else degrades to a link. + function insertEmbed() { + var sel = view.state.selection.main; + var selectedText = view.state.sliceDoc(sel.from, sel.to).trim(); + var here = pageBase().replace(/\/+$/, '') || '/'; + + openTreePicker({ + title: 'Embed — pick a file', + mode: 'file', + initialPath: here, + confirmLabel: 'NEXT', + onSelect: function (absPath) { + var container = document.createElement('div'); + + var captionInput = document.createElement('input'); + captionInput.type = 'text'; + captionInput.className = 'input'; + captionInput.placeholder = 'Caption (optional)'; + captionInput.value = selectedText; + + var alignSelect = document.createElement('select'); + alignSelect.className = 'input'; + [['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']] + .forEach(function (opt) { + var o = document.createElement('option'); + o.value = opt[0]; + o.textContent = opt[1]; + alignSelect.appendChild(o); + }); + + container.appendChild(captionInput); + container.appendChild(alignSelect); + + var handle = openModal({ + title: 'Embed — caption?', + body: container, + confirm: { + label: 'INSERT', + onConfirm: function () { + handle.close(); + insertAtCursor(embedToken( + embedDest(absPath), + captionInput.value.trim(), + alignSelect.value + )); + } + } + }); + } + }); + } + + var actions = { + undo: function () { CM.undo(view); view.focus(); }, + redo: function () { CM.redo(view); view.focus(); }, + deleteline: function () { CM.deleteLine(view); view.focus(); }, + bold: function () { wrap('**', '**', 'bold text'); }, + italic: function () { wrap('*', '*', 'italic text'); }, + h1: function () { linePrefix('# '); }, + h2: function () { linePrefix('## '); }, + h3: function () { linePrefix('### '); }, + code: function () { wrap('`', '`', 'code'); }, + codeblock: function () { wrap('```\n', '\n```', 'code'); }, + quote: function () { linePrefix('> '); }, + link: function () { wrap('[', '](url)', 'link text'); }, + wikilink: insertWikilink, + embed: insertEmbed, + ul: function () { linePrefix('- '); }, + ol: function () { linePrefix('1. '); }, + task: function () { linePrefix('- [ ] '); }, + hr: function () { wrap('\n\n---\n\n', '', ''); }, + fmttable: function () { applyTableOp(T.formatTableText); }, + tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); }, + tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); }, + tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); }, + tblinsertcol: function () { applyTableOp(T.insertColumn); }, + tbldeletecol: function () { applyTableOp(T.deleteColumn); }, + tblinsertrow: function () { applyTableOp(T.insertRow); }, + tbldeleterow: function () { applyTableOp(T.deleteRow); }, + dateiso: function () { insertAtCursor(D.isoDate()); }, + datelong: function () { insertAtCursor(D.longDate()); }, + movie: function () { M.run(movieCtx); }, + }; + + return { + view: view, + actions: actions, + getValue: function () { return view.state.doc.toString(); }, + focus: function () { view.focus(); }, + destroy: function () { view.destroy(); }, + }; + } + + // The toolbar scrolls horizontally (so it clips its absolutely-positioned + // menus) and on mobile is fixed to the bottom of the viewport. Pin an open + // menu to the viewport so it escapes the clip, opening upward when there + // isn't room below it (the bottom-toolbar case). + function pinMenu(toggle, menu) { + if (!menu.classList.contains('is-open')) return; + var r = toggle.getBoundingClientRect(); + var vh = window.innerHeight; + menu.style.position = 'fixed'; + menu.style.overflowY = 'auto'; + var spaceBelow = vh - r.bottom; + var spaceAbove = r.top; + if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) { + menu.style.top = 'auto'; + menu.style.bottom = (vh - r.top) + 'px'; + menu.style.maxHeight = (spaceAbove - 8) + 'px'; + } else { + menu.style.bottom = 'auto'; + menu.style.top = r.bottom + 'px'; + menu.style.maxHeight = (spaceBelow - 8) + 'px'; + } + var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4); + menu.style.left = Math.max(4, left) + 'px'; + } + + function bind(root, getActions) { + function run(name) { + var actions = getActions(); + var action = actions && actions[name]; + if (action) action(); + } + + var keyMap = {}; + root.querySelectorAll('[data-action]').forEach(function (btn) { + btn.addEventListener('click', function () { run(btn.dataset.action); }); + if (btn.dataset.key) keyMap[btn.dataset.key] = btn.dataset.action; + }); + + // Keep the editor focused when a toolbar button is tapped. Without this the + // button steals focus on mousedown, which dismisses the mobile soft keyboard + // before the action runs (and view.focus() can't reopen it without a direct + // gesture). preventDefault on mousedown blocks the focus shift; click still + // fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes + // dropdown toggles, which also must not pull focus off the editor. + var toolbar = root.classList.contains('editor-toolbar') ? root : root.querySelector('.editor-toolbar'); + if (toolbar) { + toolbar.addEventListener('mousedown', function (e) { + if (e.target.closest('.btn')) e.preventDefault(); + }); + } + + document.addEventListener('keydown', function (e) { + if (!e.altKey || !e.shiftKey || !getActions()) return; + // Shift+digit produces a layout-dependent character in e.key (e.g. "!" + // on US, "!" on DE), so fall back to e.code for digit rows. + var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key; + var name = keyMap[key]; + if (name) { + e.preventDefault(); + run(name); + } + }); + + root.querySelectorAll('.dropdown-toggle').forEach(function (toggle) { + wireDropdown(toggle); + var menu = toggle.parentElement.querySelector('.dropdown-menu'); + if (!menu || !toolbar || !toolbar.contains(toggle)) return; + // Runs after wireDropdown's own click handler has toggled is-open. + toggle.addEventListener('click', function () { pinMenu(toggle, menu); }); + }); + } + + return { create: create, bind: bind }; +})(); diff --git a/assets/editor/main.html b/assets/editor/main.html index 9f79124..be148f4 100644 --- a/assets/editor/main.html +++ b/assets/editor/main.html @@ -11,70 +11,7 @@ {{if ge .SectionIndex 0}}{{end}} {{if .Span}}{{end}} {{if ge .InsertBefore 0}}{{end}} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ {{template "editor-toolbar"}}
@@ -83,5 +20,6 @@ + {{end}} diff --git a/assets/editor/main.js b/assets/editor/main.js index 351646a..ce02a0d 100644 --- a/assets/editor/main.js +++ b/assets/editor/main.js @@ -1,161 +1,12 @@ +// The page editor: one markdown editor (core.js) inside the edit form, saved +// with the header's SAVE button. (function () { var mount = document.getElementById('editor'); var hidden = document.getElementById('editor-content'); if (!mount || !hidden || !window.CM) return; var form = hidden.closest('form'); - - var T = EditorTables; - var D = EditorDates; - var M = EditorMovie; - - // --- CodeMirror setup --- - - // Shift+Enter (new table row below) / Shift+Delete (delete table row) run at - // highest precedence so they win over CM's default newline/forward-delete. - // Returning false (no table at cursor) lets CM fall back to its default. - function tableKey(fn) { - return function (view) { - var result = fn(view.state.doc.toString(), view.state.selection.main.head); - if (!result) return false; - dispatchFullReplace(result); - return true; - }; - } - - // Enter/Tab cell navigation must yield to an open autocomplete popup so those - // keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell). - // The table keymap sits at Prec.highest, so without this guard it would - // swallow the key before the completion keymap ever sees it. Detect the popup - // via its tooltip element rather than exporting completionStatus from the - // vendored bundle (which would force a bundle rebuild). - function completionOpen() { - return !!document.querySelector('.cm-tooltip-autocomplete'); - } - - function tableNavKey(fn) { - var handler = tableKey(fn); - return function (view) { - if (completionOpen()) return false; - return handler(view); - }; - } - - var tableKeymap = [ - { key: 'Shift-Enter', run: tableKey(T.insertRowBelow) }, - { key: 'Shift-Delete', run: tableKey(T.deleteRow) }, - { key: 'Enter', run: tableNavKey(T.nextRowSameColumn) }, - { key: 'Tab', run: tableNavKey(T.nextCell) }, - { key: 'Shift-Tab', run: tableNavKey(T.prevCell) }, - ]; - - var state = CM.EditorState.create({ - doc: hidden.value, - // Start with the caret at the end of the existing content — editing an - // existing page almost always means appending, and the default (start - // of doc) buries the caret behind the leading heading. - selection: { anchor: hidden.value.length }, - extensions: [ - CM.history(), - CM.drawSelection(), - CM.indentOnInput(), - CM.EditorView.lineWrapping, - // Enable native browser spellcheck on the contenteditable surface - // (CM6 leaves it off by default). autocapitalize helps prose entry - // on the Android/mobile path; CM's DOM observer absorbs corrections. - 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, - CM.Prec.highest(CM.keymap.of(tableKeymap)), - CM.keymap.of([].concat( - CM.closeBracketsKeymap, - CM.completionKeymap, - CM.markdownKeymap, - CM.defaultKeymap, - CM.historyKeymap, - [CM.indentWithTab] - )), - ], - }); - - var view = new CM.EditorView({ state: state, parent: mount }); - view.focus(); - - // --- CM document helpers --- - - function dispatchFullReplace(result) { - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: result.text }, - selection: { anchor: result.cursor }, - scrollIntoView: true, - }); - view.focus(); - } - - function insertAtCursor(s) { - var sel = view.state.selection.main; - view.dispatch({ - changes: { from: sel.from, to: sel.to, insert: s }, - selection: { anchor: sel.from + s.length }, - scrollIntoView: true, - }); - view.focus(); - } - - function wrap(before, after, placeholder) { - var sel = view.state.selection.main; - var hadSelection = sel.to > sel.from; - var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder; - var insert = before + selected + after; - var anchor, head; - if (hadSelection) { - anchor = head = sel.from + insert.length; - } else { - anchor = sel.from + before.length; - head = anchor + placeholder.length; - } - view.dispatch({ - changes: { from: sel.from, to: sel.to, insert: insert }, - selection: { anchor: anchor, head: head }, - scrollIntoView: true, - }); - view.focus(); - } - - function linePrefix(prefix) { - var sel = view.state.selection.main; - var line = view.state.doc.lineAt(sel.from); - view.dispatch({ - changes: { from: line.from, to: line.from, insert: prefix }, - selection: { anchor: sel.from + prefix.length }, - scrollIntoView: true, - }); - view.focus(); - } - - function applyTableOp(fn, arg) { - var text = view.state.doc.toString(); - var pos = view.state.selection.main.head; - var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos); - if (result) dispatchFullReplace(result); - } - - // Adapter passed to movie.js so it reads/writes the CM document instead of a - // textarea (replace() dispatches a transaction; cursor lands after the block). - var movieCtx = { - getValue: function () { return view.state.doc.toString(); }, - replace: function (start, end, text) { - view.dispatch({ - changes: { from: start, to: end, insert: text }, - selection: { anchor: start + text.length }, - scrollIntoView: true, - }); - view.focus(); - }, - }; + var editor = MarkdownEditor.create(mount, { doc: hidden.value }); // --- Content sync --- @@ -163,7 +14,7 @@ // must not depend on CM focus or async state (decision 8). requestSubmit // fires this listener even for the ALT+SHIFT+S path. function syncContent() { - hidden.value = view.state.doc.toString(); + hidden.value = editor.getValue(); } // Save POSTs via fetch and then rewrites the *editor's* history entry with @@ -208,246 +59,8 @@ // --- Actions --- - var actions = { - save: function () { form.requestSubmit(); }, - undo: function () { CM.undo(view); view.focus(); }, - redo: function () { CM.redo(view); view.focus(); }, - deleteline: function () { CM.deleteLine(view); view.focus(); }, - bold: function () { wrap('**', '**', 'bold text'); }, - italic: function () { wrap('*', '*', 'italic text'); }, - h1: function () { linePrefix('# '); }, - h2: function () { linePrefix('## '); }, - h3: function () { linePrefix('### '); }, - code: function () { wrap('`', '`', 'code'); }, - codeblock: function () { wrap('```\n', '\n```', 'code'); }, - quote: function () { linePrefix('> '); }, - link: function () { wrap('[', '](url)', 'link text'); }, - wikilink: insertWikilink, - embed: insertEmbed, - ul: function () { linePrefix('- '); }, - ol: function () { linePrefix('1. '); }, - task: function () { linePrefix('- [ ] '); }, - hr: function () { wrap('\n\n---\n\n', '', ''); }, - fmttable: function () { applyTableOp(T.formatTableText); }, - tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); }, - tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); }, - tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); }, - tblinsertcol: function () { applyTableOp(T.insertColumn); }, - tbldeletecol: function () { applyTableOp(T.deleteColumn); }, - tblinsertrow: function () { applyTableOp(T.insertRow); }, - tbldeleterow: function () { applyTableOp(T.deleteRow); }, - dateiso: function () { insertAtCursor(D.isoDate()); }, - datelong: function () { insertAtCursor(D.longDate()); }, - movie: function () { M.run(movieCtx); }, - }; + editor.actions.save = function () { form.requestSubmit(); }; - // Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead - // plus an optional display-text field, inserting [[query]] or - // [[query::display]] at the cursor. Picking a suggestion only fills in that - // page's name — the link resolves through search at click time, so a path - // never enters the document. Inline `[[` typing uses the same suggestions - // via wikicomplete.js. - function insertWikilink() { - var sel = view.state.selection.main; - var selectedText = view.state.sliceDoc(sel.from, sel.to).trim(); - - var container = document.createElement('div'); - - var queryWrap = document.createElement('div'); - var queryInput = document.createElement('input'); - queryInput.type = 'text'; - queryInput.className = 'input'; - queryInput.placeholder = 'Search query'; - // A selection is the query, not the label: [[selection]] renders with - // the same visible text anyway, and it seeds the typeahead. - queryInput.value = selectedText; - queryWrap.appendChild(queryInput); - - var displayInput = document.createElement('input'); - displayInput.type = 'text'; - displayInput.className = 'input'; - displayInput.placeholder = 'Display text (optional)'; - - container.appendChild(queryWrap); - container.appendChild(displayInput); - - var handle = openModal({ - title: 'Insert link', - body: container, - confirm: { - label: 'INSERT', - initiallyDisabled: !selectedText, - onConfirm: function () { - var query = queryInput.value.trim(); - if (!query) return; - var display = displayInput.value.trim(); - handle.close(); - insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]'); - } - } - }); - - function updateConfirm() { - handle.setConfirmDisabled(!queryInput.value.trim()); - } - queryInput.addEventListener('input', updateConfirm); - - window.attachSuggestions(queryInput, { - showFooter: false, - container: queryWrap, - onPick: function (r) { - queryInput.value = r.name; - updateConfirm(); - displayInput.focus(); - } - }); - } - - // embedDest turns the absolute wiki path the tree picker hands back into the - // destination written to the document: relative to the page being edited - // when the file lives under it, absolute otherwise. Pages render at a - // slash-terminated URL, so a relative destination resolves against the page - // in the browser. - function embedDest(absPath) { - var base = decodeURIComponent(window.location.pathname); - if (base.slice(-1) !== '/') base += '/'; - return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath; - } - - // embedToken formats a Markdown image. A destination carrying spaces or - // brackets goes in — CommonMark's way of keeping it in one - // piece — and the title slot holds the alignment keyword the renderer reads. - // Right is the renderer's default, so it is left off. - function embedToken(dest, caption, align) { - if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>'; - var title = (align && align !== 'right') ? ' "' + align + '"' : ''; - return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')'; - } - - // Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a - // caption and an alignment. Images render as a floated figure; anything - // else degrades to a link. - function insertEmbed() { - var sel = view.state.selection.main; - var selectedText = view.state.sliceDoc(sel.from, sel.to).trim(); - var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/'; - - openTreePicker({ - title: 'Embed — pick a file', - mode: 'file', - initialPath: here, - confirmLabel: 'NEXT', - onSelect: function (absPath) { - var container = document.createElement('div'); - - var captionInput = document.createElement('input'); - captionInput.type = 'text'; - captionInput.className = 'input'; - captionInput.placeholder = 'Caption (optional)'; - captionInput.value = selectedText; - - var alignSelect = document.createElement('select'); - alignSelect.className = 'input'; - [['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']] - .forEach(function (opt) { - var o = document.createElement('option'); - o.value = opt[0]; - o.textContent = opt[1]; - alignSelect.appendChild(o); - }); - - container.appendChild(captionInput); - container.appendChild(alignSelect); - - var handle = openModal({ - title: 'Embed — caption?', - body: container, - confirm: { - label: 'INSERT', - onConfirm: function () { - handle.close(); - insertAtCursor(embedToken( - embedDest(absPath), - captionInput.value.trim(), - alignSelect.value - )); - } - } - }); - } - }); - } - - // --- Keyboard shortcut registration --- - - var keyMap = {}; - document.querySelectorAll('[data-action]').forEach(function (btn) { - btn.addEventListener('click', function () { - var action = actions[btn.dataset.action]; - if (action) action(); - }); - if (btn.dataset.key) { - keyMap[btn.dataset.key] = actions[btn.dataset.action]; - } - }); - - // Keep the editor focused when a toolbar button is tapped. Without this the - // button steals focus on mousedown, which dismisses the mobile soft keyboard - // before the action runs (and view.focus() can't reopen it without a direct - // gesture). preventDefault on mousedown blocks the focus shift; click still - // fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes - // dropdown toggles, which also must not pull focus off the editor. - var toolbar = document.querySelector('.editor-toolbar'); - if (toolbar) { - toolbar.addEventListener('mousedown', function (e) { - if (e.target.closest('.btn')) e.preventDefault(); - }); - } - - document.addEventListener('keydown', function (e) { - if (!e.altKey || !e.shiftKey) return; - // Shift+digit produces a layout-dependent character in e.key (e.g. "!" - // on US, "!" on DE), so fall back to e.code for digit rows. - var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key; - var action = keyMap[key]; - if (action) { - e.preventDefault(); - action(); - } - }); - - // --- Dropdowns --- - - // The toolbar scrolls horizontally (so it clips its absolutely-positioned - // menus) and on mobile is fixed to the bottom of the viewport. Pin an open - // menu to the viewport so it escapes the clip, opening upward when there - // isn't room below it (the bottom-toolbar case). - function pinMenu(toggle, menu) { - if (!menu.classList.contains('is-open')) return; - var r = toggle.getBoundingClientRect(); - var vh = window.innerHeight; - menu.style.position = 'fixed'; - menu.style.overflowY = 'auto'; - var spaceBelow = vh - r.bottom; - var spaceAbove = r.top; - if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) { - menu.style.top = 'auto'; - menu.style.bottom = (vh - r.top) + 'px'; - menu.style.maxHeight = (spaceAbove - 8) + 'px'; - } else { - menu.style.bottom = 'auto'; - menu.style.top = r.bottom + 'px'; - menu.style.maxHeight = (spaceBelow - 8) + 'px'; - } - var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4); - menu.style.left = Math.max(4, left) + 'px'; - } - - document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) { - wireDropdown(toggle); - var menu = toggle.parentElement.querySelector('.dropdown-menu'); - if (!menu || !toolbar || !toolbar.contains(toggle)) return; - // Runs after wireDropdown's own click handler has toggled is-open. - toggle.addEventListener('click', function () { pinMenu(toggle, menu); }); - }); + // The toolbar and the header SAVE button both name actions. + MarkdownEditor.bind(document.body, function () { return editor.actions; }); })(); diff --git a/assets/editor/toolbar.html b/assets/editor/toolbar.html new file mode 100644 index 0000000..d62b467 --- /dev/null +++ b/assets/editor/toolbar.html @@ -0,0 +1,67 @@ +{{/* The markdown editor's toolbar, shared by the page editor and the canvas +node editor. Buttons name an action of editor/core.js (data-action) and its +Alt+Shift shortcut (data-key). */}} +{{define "editor-toolbar"}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{end}} diff --git a/assets/hx.js b/assets/hx.js index ae61c5b..2e96163 100644 --- a/assets/hx.js +++ b/assets/hx.js @@ -10,14 +10,26 @@ return document.body.getAttribute('hx-boost') === 'true'; } + // Only wiki pages are boosted: same origin, a slash-terminated path, no + // query except a search (?q=). Files, the editor (?edit), internal + // endpoints (/_…) and POST forms navigate normally. + function isPageURL(url) { + if (url.origin !== location.origin) return false; + if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false; + var ok = true; + url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; }); + return ok; + } + // A server-sent HX-Redirect follows a mutation (move, delete, …): load the // target into the current history entry rather than pushing a new one, so - // Back skips the stale pre-mutation page. Boosted pages swap it in like a - // navigation; elsewhere replaceState + reload (not location.replace: a + // Back skips the stale pre-mutation page. Boosted pages swap a page target + // in like a navigation; anything else (or a target that is not a page, + // like a new canvas) gets replaceState + reload (not location.replace: a // target differing only by #fragment would otherwise not re-fetch). htmx.location = { set href(target) { - if (boosting()) { + if (boosting() && isPageURL(new URL(target, location.href))) { if (window.closeModal) closeModal(); htmx.ajax('GET', target, { target: '.shell', @@ -60,17 +72,6 @@ // snapshots that could show a page from before a move or delete). htmx.config.scrollIntoViewOnBoost = false; - // Only wiki pages are boosted: same origin, a slash-terminated path, no - // query except a search (?q=). Files, the editor (?edit), internal - // endpoints (/_…) and POST forms navigate normally. - function isPageURL(url) { - if (url.origin !== location.origin) return false; - if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false; - var ok = true; - url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; }); - return ok; - } - // htmx decides per element whether it is boosted, right after this event. document.addEventListener('htmx:beforeProcessNode', function (e) { var elt = e.target; diff --git a/assets/icons/canvas.svg b/assets/icons/canvas.svg new file mode 100644 index 0000000..56d51f8 --- /dev/null +++ b/assets/icons/canvas.svg @@ -0,0 +1,4 @@ + + + diff --git a/assets/layout.html b/assets/layout.html index 22f736a..4ff7585 100644 --- a/assets/layout.html +++ b/assets/layout.html @@ -23,8 +23,9 @@ {{/* Outside the editor, links to pages are boosted: htmx swaps .shell (plus title and page actions, see "partial") instead of loading a new - document. hx.js decides which links count as pages. */}} - + document. hx.js decides which links count as pages. A view can opt out by + overriding "boost" (the canvas does: it saves on pagehide). */}} +