JSON canvas initial
This commit is contained in:
@@ -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 — `` — 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 `<Name>.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 |
|
||||
|------|-------------|
|
||||
| `<page>/?canvas=<Name>` | the canvas view (canonical) |
|
||||
| `<page>/<Name>.canvas` | redirects to the canonical form |
|
||||
| `<page>/<Name>.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 `.<Name>.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):
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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 -> <g>
|
||||
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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,88 @@
|
||||
{{define "headerActions"}}
|
||||
<a class="btn" href="{{.PostURL}}" title="Back to the page">PAGE</a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn dropdown-toggle" data-action="canvas-menu" title="Canvas actions">CANVAS▾</button>
|
||||
<div class="dropdown-menu align-right">
|
||||
<button type="button" class="btn btn-block" data-action="canvas-rename">Rename…</button>
|
||||
<button type="button" class="btn btn-block danger" data-action="canvas-delete">Delete canvas</button>
|
||||
</div>
|
||||
</span>
|
||||
<button class="btn" type="button" data-action="canvas-save" title="Save (Ctrl+S)">SAVE</button>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<script>
|
||||
// Full-bleed view mode: the canvas surface owns the viewport instead of the
|
||||
// centred reading column. Set here rather than server-side for the same
|
||||
// reason the editor does it — one class, no extra template plumbing.
|
||||
document.body.classList.add('canvas-mode');
|
||||
</script>
|
||||
{{if .CanvasError}}
|
||||
<div class="content">
|
||||
<h1>{{.ActiveCanvas}}</h1>
|
||||
<p class="canvas-broken">This canvas could not be opened — {{.CanvasError}}</p>
|
||||
<p>Fix it in the file itself, then reload.
|
||||
<a href="{{.ActiveCanvas}}.canvas?raw">view the raw file</a> ·
|
||||
<a href="{{.PostURL}}">back to the page</a></p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="canvas-app">
|
||||
<nav class="canvas-tabs row" aria-label="Page views">
|
||||
<a class="canvas-tab" href="{{.PostURL}}">PAGE</a>
|
||||
{{range .Canvases}}<a class="canvas-tab{{if .Active}} is-active{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||
</nav>
|
||||
<div class="canvas-toolbar row">
|
||||
<button type="button" class="btn btn-tool" data-action="undo" title="Undo (Ctrl+Z)" disabled>↶</button>
|
||||
<button type="button" class="btn btn-tool" data-action="redo" title="Redo (Ctrl+Shift+Z)" disabled>↷</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="add-text" title="Add text node (T)">TEXT</button>
|
||||
<button type="button" class="btn btn-tool" data-action="add-file" title="Add file node (F)">FILE</button>
|
||||
<button type="button" class="btn btn-tool" data-action="add-link" title="Add link node (L)">LINK</button>
|
||||
<button type="button" class="btn btn-tool" data-action="add-group" title="Add group (G)">GROUP</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" data-action="color-menu" title="Colour of the selection">■▾</button>
|
||||
<div class="dropdown-menu canvas-color-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-color="">Default</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-color="1"><span class="canvas-swatch" data-swatch="1"></span> Red</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-color="2"><span class="canvas-swatch" data-swatch="2"></span> Orange</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-color="3"><span class="canvas-swatch" data-swatch="3"></span> Yellow</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-color="4"><span class="canvas-swatch" data-swatch="4"></span> Green</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-color="5"><span class="canvas-swatch" data-swatch="5"></span> Cyan</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-color="6"><span class="canvas-swatch" data-swatch="6"></span> Purple</button>
|
||||
</div>
|
||||
</span>
|
||||
<button type="button" class="btn btn-tool" data-action="delete-selection" title="Delete selection (Del)">×</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="zoom-out" title="Zoom out (−)">−</button>
|
||||
<button type="button" class="btn btn-tool" data-action="zoom-reset" title="Reset zoom to 100% (0)"><span data-canvas-zoom>100%</span></button>
|
||||
<button type="button" class="btn btn-tool" data-action="zoom-in" title="Zoom in (+)">+</button>
|
||||
<button type="button" class="btn btn-tool" data-action="fit" title="Fit to content (9)">FIT</button>
|
||||
<span class="canvas-status muted" data-canvas-status></span>
|
||||
</div>
|
||||
<div class="canvas-surface" id="canvas-surface" tabindex="0">
|
||||
<div class="canvas-world" id="canvas-world">
|
||||
<div class="canvas-layer" id="canvas-groups"></div>
|
||||
<svg class="canvas-edges" id="canvas-edges" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
<div class="canvas-layer" id="canvas-nodes"></div>
|
||||
</div>
|
||||
<div class="canvas-marquee" id="canvas-marquee" hidden></div>
|
||||
<div class="canvas-hint muted" data-canvas-hint hidden>
|
||||
Empty canvas — use the toolbar to add a node. Drag the background to pan, ctrl+wheel to zoom.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.CANVAS_DATA = {{.CanvasJSON}};</script>
|
||||
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
|
||||
<script src="/_/editor/wikicomplete.js"></script>
|
||||
<script src="/_/canvas/doc.js"></script>
|
||||
<script src="/_/canvas/view.js"></script>
|
||||
<script src="/_/canvas/select.js"></script>
|
||||
<script src="/_/canvas/nodes.js"></script>
|
||||
<script src="/_/canvas/edges.js"></script>
|
||||
<script src="/_/canvas/interact.js"></script>
|
||||
<script src="/_/canvas/edit.js"></script>
|
||||
<script src="/_/canvas/save.js"></script>
|
||||
<script src="/_/canvas/main.js"></script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -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();
|
||||
})();
|
||||
Binary file not shown.
@@ -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); }
|
||||
};
|
||||
})();
|
||||
@@ -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); }
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
// view.js — pan and zoom.
|
||||
//
|
||||
// The whole scene lives in one transformed <div>, 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
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<path d="M1 2h6v5H1zM9 9h6v5H9zM4 7v4h5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 249 B |
+5
-5
@@ -15,7 +15,7 @@
|
||||
<script src="/_/search-suggest.js" defer></script>
|
||||
<script src="/_/tree-picker.js"></script>
|
||||
<script src="/_/companion.js" defer></script>
|
||||
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
|
||||
{{if .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
|
||||
<script src="/_/tree-sidebar.js" defer></script>{{end}}
|
||||
{{block "headScripts" .}}{{end}}
|
||||
</head>
|
||||
@@ -24,13 +24,13 @@
|
||||
<nav class="breadcrumb row">
|
||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||
</nav>
|
||||
{{if not .EditMode}}
|
||||
{{if .ShowPageChrome}}
|
||||
<form class="search-form" action="/" method="get">
|
||||
<input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||
</form>
|
||||
{{end}}
|
||||
<div class="header-actions row">
|
||||
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
|
||||
{{if not .ShowPageChrome}}{{block "headerActions" .}}{{end}}{{else}}
|
||||
<span class="dropdown actions">
|
||||
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
||||
<div class="dropdown-menu align-right actions-menu">
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell">
|
||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||
{{if .ShowPageChrome}}<aside class="tree-sidebar col">
|
||||
<div class="tree-scroll"></div>
|
||||
</aside>{{end}}
|
||||
<div class="center">
|
||||
@@ -58,7 +58,7 @@
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
|
||||
</div>
|
||||
{{block "extras" .}}{{end}}
|
||||
</body>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+13
-1
@@ -1,4 +1,5 @@
|
||||
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
|
||||
{{define "headScripts"}}<script src="/_/page/actions.js"></script>
|
||||
<script src="/_/page/canvas-actions.js"></script>{{end}}
|
||||
|
||||
{{define "headerActions"}}{{if .CanEdit}}
|
||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
@@ -8,9 +9,20 @@
|
||||
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||
{{end}}
|
||||
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
|
||||
<button class="btn btn-block" onclick="newCanvas()" title="New canvas">NEW CANVAS</button>
|
||||
{{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}}
|
||||
<nav class="canvas-tabs row" aria-label="Page views">
|
||||
<span class="canvas-tab is-active">PAGE</span>
|
||||
{{range .Canvases}}<a class="canvas-tab" href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||
{{if .CanEdit}}<button type="button" class="canvas-tab canvas-tab-add" onclick="newCanvas()" title="New canvas">+</button>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<div class="content">{{.Content}}</div>
|
||||
{{end}}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 `<Name>.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
|
||||
// <img> 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(`<a class="canvas-media" href="` + href + `"><img src="` +
|
||||
html.EscapeString(thumbURL(fileURL(strings.TrimPrefix(target, "/")), 800)) + `" alt="" loading="lazy"></a>`)
|
||||
case isVideoFile(name):
|
||||
return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`)
|
||||
case strings.EqualFold(path.Ext(name), ".pdf"):
|
||||
return template.HTML(`<embed class="canvas-media" type="application/pdf" src="` + href + `">`)
|
||||
case strings.EqualFold(path.Ext(name), ".md"):
|
||||
return h.renderMarkdownFile(fsPath, file, subpath)
|
||||
default:
|
||||
return template.HTML(`<p class="canvas-file-fallback"><a href="` + href + `">` +
|
||||
html.EscapeString(name) + `</a></p>`)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(`<p class="canvas-broken">` + html.EscapeString(target) +
|
||||
` <span class="muted">(` + html.EscapeString(reason) + `)</span></p>`)
|
||||
}
|
||||
|
||||
// --- request handlers ---
|
||||
|
||||
// serveCanvas renders the canvas view of a page. Reached from serveDir when
|
||||
// the request carries ?canvas=<name>.
|
||||
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 <script> block.
|
||||
blob, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.CanvasJSON = template.JS(blob)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data.RenderMS = elapsedMS(r)
|
||||
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCanvasPost dispatches every POST carrying ?canvas=. It is checked
|
||||
// before the page-level delete/move actions in handlePost so `?canvas=X&delete`
|
||||
// removes the canvas rather than the whole page.
|
||||
func (h *handler) handleCanvasPost(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||
if !canvasNameRe.MatchString(name) {
|
||||
http.Error(w, "bad canvas name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
switch {
|
||||
case query.Has("create"):
|
||||
h.handleCanvasCreate(w, r, urlPath, fsPath, name)
|
||||
case query.Has("delete"):
|
||||
h.handleCanvasDelete(w, r, urlPath, fsPath, name)
|
||||
case query.Has("rename"):
|
||||
h.handleCanvasRename(w, r, urlPath, fsPath, name, query.Get("rename"))
|
||||
default:
|
||||
h.handleCanvasSave(w, r, urlPath, fsPath, name)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCanvasSave replaces a canvas with the posted document. The write is
|
||||
// gated on four checks in order — parse, validate, version, non-empty — so a
|
||||
// malformed or stale payload never reaches the file.
|
||||
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasMaxBytes))
|
||||
if err != nil {
|
||||
http.Error(w, "request too large or truncated", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
doc, err := parseCanvas(body)
|
||||
if err != nil {
|
||||
http.Error(w, "rejected: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p := canvasPath(fsPath, name)
|
||||
current, readErr := os.ReadFile(p)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
http.Error(w, "read failed: "+readErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if want := r.Header.Get("X-Canvas-Version"); want != "" && want != canvasHash(current) {
|
||||
http.Error(w, "the canvas changed since you opened it — reload to get the current version",
|
||||
http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// A save must never blank a canvas. An empty document is indistinguishable
|
||||
// from a client that lost its state, so emptying is only allowed when the
|
||||
// caller says so explicitly — the same reasoning as the empty-content guard
|
||||
// on page saves.
|
||||
if len(doc.Nodes) == 0 && !r.URL.Query().Has("empty") {
|
||||
if cur, err := parseCanvas(current); err == nil && len(cur.Nodes) > 0 {
|
||||
http.Error(w, fmt.Sprintf("refusing to replace %d nodes with an empty canvas", len(cur.Nodes)),
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(doc, "", "\t")
|
||||
if err != nil {
|
||||
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out = append(out, '\n')
|
||||
|
||||
if len(current) > 0 {
|
||||
if err := writeFileAtomic(canvasBackupPath(fsPath, name), current, 0644); err != nil {
|
||||
log.Printf("canvas backup %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
if err := writeFileAtomic(p, out, 0644); err != nil {
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Hand back the new token so the client can keep editing without reloading.
|
||||
w.Header().Set("X-Canvas-Version", canvasHash(out))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p := canvasPath(fsPath, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(p, []byte("{}\n"), 0644); err != nil {
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) handleCanvasDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||
if err := os.Remove(canvasPath(fsPath, name)); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = os.Remove(canvasBackupPath(fsPath, name))
|
||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) handleCanvasRename(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name, newName string) {
|
||||
if !canvasNameRe.MatchString(newName) {
|
||||
http.Error(w, "bad canvas name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if newName == name {
|
||||
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
dst := canvasPath(fsPath, newName)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(canvasPath(fsPath, name), dst); err != nil {
|
||||
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = os.Remove(canvasBackupPath(fsPath, name))
|
||||
http.Redirect(w, r, canvasURL(urlPath, newName), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleCanvasRender renders one node's content on demand. It is stateless and
|
||||
// takes the content in the request rather than reading it from the canvas file:
|
||||
// the node being re-rendered has just been edited in the browser and is not on
|
||||
// disk yet. Everything already on disk arrives pre-rendered with the page.
|
||||
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAuth(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, canvasRenderMaxBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var out template.HTML
|
||||
switch r.FormValue("type") {
|
||||
case canvasNodeFile:
|
||||
out = h.renderFileNode(r.FormValue("file"), r.FormValue("subpath"))
|
||||
case canvasNodeText:
|
||||
out = renderMarkdown([]byte(r.FormValue("text")))
|
||||
default:
|
||||
http.Error(w, "bad node type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, string(out))
|
||||
}
|
||||
@@ -48,6 +48,7 @@ var (
|
||||
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
||||
canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/canvas/main.html"))
|
||||
)
|
||||
|
||||
// specialPage is the result returned by a pageTypeHandler.
|
||||
@@ -125,6 +126,7 @@ func main() {
|
||||
http.HandleFunc("/_logout", h.handleLogout)
|
||||
http.HandleFunc("/_reindex", h.handleReindex)
|
||||
http.HandleFunc("/_search", h.handleSearchSuggest)
|
||||
http.HandleFunc("/_canvas/render", h.handleCanvasRender)
|
||||
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
||||
http.Handle("/", h)
|
||||
|
||||
@@ -213,6 +215,16 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A canvas is addressed as a view of its folder (?canvas=Name), so a direct
|
||||
// hit on the file collapses onto that canonical form — the same shape as
|
||||
// diary.go's virtual-URL redirects. ?raw keeps the bytes reachable.
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(urlPath, canvasExt) && !r.URL.Query().Has("raw") {
|
||||
if target, ok := canvasViewURL(urlPath); ok {
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Stat(fsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -274,6 +286,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
return
|
||||
}
|
||||
|
||||
// An explicit ?canvas= selects this page's canvas view. Checked ahead of
|
||||
// the pageTypeHandler redirect loop so an explicit view request always wins
|
||||
// over a folder-type redirect (e.g. a canvas kept in a diary year folder).
|
||||
//
|
||||
// This is deliberately not a pageTypeHandler: that interface injects content
|
||||
// into the standard page body, while a canvas replaces the whole body with a
|
||||
// full-bleed surface and its own controls — a view mode like ?edit, which is
|
||||
// likewise a template branch here rather than a handler.
|
||||
if name := r.URL.Query().Get("canvas"); name != "" {
|
||||
h.serveCanvas(w, r, urlPath, fsPath, name)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ph := range pageTypeHandlers {
|
||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
@@ -316,6 +341,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
rendered = renderMarkdown(rawMD)
|
||||
}
|
||||
|
||||
// Canvases in this folder surface as a view switcher above the content, so
|
||||
// a page and its diagrams read as one thing. Suppressed in the editor, which
|
||||
// is about the markdown only.
|
||||
var canvases []canvasRef
|
||||
if !editMode {
|
||||
canvases = listCanvases(fsPath, urlPath, "")
|
||||
}
|
||||
|
||||
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
||||
var entries []entry
|
||||
if !editMode && (special == nil || !special.SuppressListing) {
|
||||
@@ -379,6 +412,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
SpecialContent: specialContent,
|
||||
SidebarWidget: sidebarWidget,
|
||||
SuppressTOC: suppressTOC,
|
||||
Canvases: canvases,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -394,6 +428,12 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
|
||||
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||
query := r.URL.Query()
|
||||
// Checked first so ?canvas=X&delete removes that canvas rather than falling
|
||||
// through to the page-level delete below.
|
||||
if name := query.Get("canvas"); name != "" {
|
||||
h.handleCanvasPost(w, r, urlPath, fsPath, name)
|
||||
return
|
||||
}
|
||||
if query.Has("delete") {
|
||||
h.handleDelete(w, r, urlPath, fsPath)
|
||||
return
|
||||
|
||||
@@ -64,6 +64,39 @@ type pageData struct {
|
||||
SidebarWidget template.HTML
|
||||
SuppressTOC bool
|
||||
RenderMS int64
|
||||
|
||||
// Canvases lists every .canvas file in this folder as a view-switcher tab.
|
||||
// ActiveCanvas names the one currently being viewed ("" on a normal page),
|
||||
// and is what makes this page render as a canvas rather than as markdown.
|
||||
Canvases []canvasRef
|
||||
ActiveCanvas string
|
||||
// CanvasJSON is the whole canvas document plus its server-rendered node
|
||||
// HTML, inlined into the page so opening a canvas costs one request.
|
||||
CanvasJSON template.JS
|
||||
// CanvasError carries a parse/validation failure to the template, which
|
||||
// shows it in place of the canvas instead of failing the request.
|
||||
CanvasError string
|
||||
}
|
||||
|
||||
// layoutData is the contract every view built on assets/layout.html satisfies.
|
||||
// The layout is shared by pageData and searchPageData, and a method only one of
|
||||
// them has fails at render time with a half-written response rather than at
|
||||
// build time — so each is asserted against the interface here.
|
||||
type layoutData interface {
|
||||
ShowPageChrome() bool
|
||||
}
|
||||
|
||||
var (
|
||||
_ layoutData = pageData{}
|
||||
_ layoutData = searchPageData{}
|
||||
)
|
||||
|
||||
// ShowPageChrome reports whether the standard page furniture — search box,
|
||||
// folder-tree rail, right sidebar, and the collapsed ACTIONS dropdown — should
|
||||
// render. The editor and the canvas view both take the full width and supply
|
||||
// their own controls, so both opt out.
|
||||
func (d pageData) ShowPageChrome() bool {
|
||||
return !d.EditMode && d.ActiveCanvas == ""
|
||||
}
|
||||
|
||||
// Allowed values for the listing view settings. Unknown values in the file or
|
||||
@@ -130,6 +163,7 @@ var (
|
||||
iconAudio = readIcon("audio")
|
||||
iconArchive = readIcon("archive")
|
||||
iconGeneric = readIcon("generic")
|
||||
iconCanvas = readIcon("canvas")
|
||||
)
|
||||
|
||||
// renderMarkdown converts raw markdown to trusted HTML.
|
||||
@@ -211,6 +245,9 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
||||
if name == "index.md" {
|
||||
continue // rendered above, don't list it
|
||||
}
|
||||
if isCanvasFile(name) {
|
||||
continue // surfaced as a view-switcher tab above the content
|
||||
}
|
||||
f := entry{
|
||||
Icon: fileIcon(name),
|
||||
Name: name,
|
||||
@@ -300,6 +337,8 @@ func readIcon(name string) template.HTML {
|
||||
func fileIcon(name string) template.HTML {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".canvas":
|
||||
return iconCanvas
|
||||
case ".md", ".pdf":
|
||||
return iconDoc
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
||||
|
||||
@@ -49,6 +49,11 @@ type searchPageData struct {
|
||||
RenderMS int64
|
||||
}
|
||||
|
||||
// ShowPageChrome satisfies the shared layout's contract (see layoutData). The
|
||||
// results page is always ordinary chrome — it is never the editor and never a
|
||||
// canvas — but EditMode is honoured so the two views cannot drift apart.
|
||||
func (d searchPageData) ShowPageChrome() bool { return !d.EditMode }
|
||||
|
||||
// indexEntry is the shared scoreable core of both folder and file index
|
||||
// entries: a forward-slash relative path plus its pre-tokenized basename so
|
||||
// the per-query scoring loop avoids redoing the lowercasing and tokenization
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ func secionSpanEnd(sections [][]byte, start int) int {
|
||||
if level == 0 {
|
||||
return start + 1
|
||||
}
|
||||
for i := start + 1; i <= len(sections); i++ {
|
||||
for i := start + 1; i < len(sections); i++ {
|
||||
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
|
||||
return i
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user