Compare commits
4
Commits
7797d4a456
...
canvas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b48d476b39 | ||
|
|
7b451bae86 | ||
|
|
1925987459 | ||
|
|
b57e5d454b |
@@ -11,3 +11,6 @@ companion/datascape-companion-*
|
|||||||
|
|
||||||
# Editor build tooling deps (the built bundle is committed; node_modules is not)
|
# Editor build tooling deps (the built bundle is committed; node_modules is not)
|
||||||
editor-build/node_modules/
|
editor-build/node_modules/
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
I want to understand every line of code that goes into this project. Never create, edit, move, rename, or delete project files unless I explicitly ask you to do so. Instead, show me every proposed edit in the chat so I can type it in manually.
|
|
||||||
|
|
||||||
Do not run commands that modify project files, install dependencies, or change repository state unless I explicitly request that action. Instead, show me those commands in the chat so I can run them manually.
|
|
||||||
|
|
||||||
I'm an experienced developer. Do not explain syntax, APIs, programming concepts, or implementation details unless explicitly asked.
|
|
||||||
@@ -10,7 +10,9 @@ Minimal self-hosted personal wiki. Folders are pages.
|
|||||||
|
|
||||||
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
||||||
|
|
||||||
- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
@@ -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.
|
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
|
## Quick-Add Bookmarklet
|
||||||
|
|
||||||
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -186,7 +186,19 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobileDevice() {
|
||||||
|
// The companion only ships for Windows and Linux desktops, so skip it
|
||||||
|
// on phones/tablets. Checks the UA and falls back to "touch is the only
|
||||||
|
// pointer" so narrow desktop windows still get the companion.
|
||||||
|
if (/Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent)) return true;
|
||||||
|
return window.matchMedia('(pointer: coarse)').matches &&
|
||||||
|
!window.matchMedia('(any-pointer: fine)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
// Every companion element is rendered hidden, so bailing out here
|
||||||
|
// leaves the status icon and reveal buttons invisible on mobile.
|
||||||
|
if (isMobileDevice()) return;
|
||||||
probeStatus().then(function () {
|
probeStatus().then(function () {
|
||||||
updateFooterIcon();
|
updateFooterIcon();
|
||||||
wireFileLinks();
|
wireFileLinks();
|
||||||
|
|||||||
@@ -30,10 +30,11 @@
|
|||||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button>
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
|
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
|
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
|
|||||||
+102
-36
@@ -223,6 +223,7 @@
|
|||||||
quote: function () { linePrefix('> '); },
|
quote: function () { linePrefix('> '); },
|
||||||
link: function () { wrap('[', '](url)', 'link text'); },
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
wikilink: insertWikilink,
|
wikilink: insertWikilink,
|
||||||
|
embed: insertEmbed,
|
||||||
ul: function () { linePrefix('- '); },
|
ul: function () { linePrefix('- '); },
|
||||||
ol: function () { linePrefix('1. '); },
|
ol: function () { linePrefix('1. '); },
|
||||||
task: function () { linePrefix('- [ ] '); },
|
task: function () { linePrefix('- [ ] '); },
|
||||||
@@ -240,43 +241,34 @@
|
|||||||
movie: function () { M.run(movieCtx); },
|
movie: function () { M.run(movieCtx); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
|
||||||
// path, no empty/dot segments. Used to gate the modal's INSERT button.
|
// plus an optional display-text field, inserting [[query]] or
|
||||||
function isValidWikiTarget(p) {
|
// [[query::display]] at the cursor. Picking a suggestion only fills in that
|
||||||
if (!p || p[0] !== '/') return false;
|
// page's name — the link resolves through search at click time, so a path
|
||||||
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
// never enters the document. Inline `[[` typing uses the same suggestions
|
||||||
if (trimmed === '') return true;
|
// via wikicomplete.js.
|
||||||
var segs = trimmed.split('/');
|
|
||||||
for (var i = 0; i < segs.length; i++) {
|
|
||||||
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
|
|
||||||
// full /_search typeahead plus an optional display-text field, then insert
|
|
||||||
// [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
|
|
||||||
// the folder-scoped completion in wikicomplete.js instead.)
|
|
||||||
function insertWikilink() {
|
function insertWikilink() {
|
||||||
var sel = view.state.selection.main;
|
var sel = view.state.selection.main;
|
||||||
var selectedText = view.state.sliceDoc(sel.from, sel.to);
|
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||||
|
|
||||||
var container = document.createElement('div');
|
var container = document.createElement('div');
|
||||||
|
|
||||||
var targetWrap = document.createElement('div');
|
var queryWrap = document.createElement('div');
|
||||||
var targetInput = document.createElement('input');
|
var queryInput = document.createElement('input');
|
||||||
targetInput.type = 'text';
|
queryInput.type = 'text';
|
||||||
targetInput.className = 'input';
|
queryInput.className = 'input';
|
||||||
targetInput.placeholder = 'Page path or search…';
|
queryInput.placeholder = 'Search query';
|
||||||
targetWrap.appendChild(targetInput);
|
// A selection is the query, not the label: [[selection]] renders with
|
||||||
|
// the same visible text anyway, and it seeds the typeahead.
|
||||||
|
queryInput.value = selectedText;
|
||||||
|
queryWrap.appendChild(queryInput);
|
||||||
|
|
||||||
var displayInput = document.createElement('input');
|
var displayInput = document.createElement('input');
|
||||||
displayInput.type = 'text';
|
displayInput.type = 'text';
|
||||||
displayInput.className = 'input';
|
displayInput.className = 'input';
|
||||||
displayInput.placeholder = 'Display text (optional)';
|
displayInput.placeholder = 'Display text (optional)';
|
||||||
if (selectedText) displayInput.value = selectedText;
|
|
||||||
|
|
||||||
container.appendChild(targetWrap);
|
container.appendChild(queryWrap);
|
||||||
container.appendChild(displayInput);
|
container.appendChild(displayInput);
|
||||||
|
|
||||||
var handle = openModal({
|
var handle = openModal({
|
||||||
@@ -284,30 +276,104 @@
|
|||||||
body: container,
|
body: container,
|
||||||
confirm: {
|
confirm: {
|
||||||
label: 'INSERT',
|
label: 'INSERT',
|
||||||
initiallyDisabled: true,
|
initiallyDisabled: !selectedText,
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
var target = targetInput.value.trim();
|
var query = queryInput.value.trim();
|
||||||
if (!isValidWikiTarget(target)) return;
|
if (!query) return;
|
||||||
var display = displayInput.value.trim();
|
var display = displayInput.value.trim();
|
||||||
handle.close();
|
handle.close();
|
||||||
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
|
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateConfirm() {
|
function updateConfirm() {
|
||||||
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
|
handle.setConfirmDisabled(!queryInput.value.trim());
|
||||||
}
|
}
|
||||||
targetInput.addEventListener('input', updateConfirm);
|
queryInput.addEventListener('input', updateConfirm);
|
||||||
|
|
||||||
window.attachSuggestions(targetInput, {
|
window.attachSuggestions(queryInput, {
|
||||||
showFooter: false,
|
showFooter: false,
|
||||||
container: targetWrap,
|
container: queryWrap,
|
||||||
onPick: function (r) {
|
onPick: function (r) {
|
||||||
targetInput.value = '/' + r.path;
|
queryInput.value = r.name;
|
||||||
updateConfirm();
|
updateConfirm();
|
||||||
displayInput.focus();
|
displayInput.focus();
|
||||||
displayInput.select();
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedDest turns the absolute wiki path the tree picker hands back into the
|
||||||
|
// destination written to the document: relative to the page being edited
|
||||||
|
// when the file lives under it, absolute otherwise. Pages render at a
|
||||||
|
// slash-terminated URL, so a relative destination resolves against the page
|
||||||
|
// in the browser.
|
||||||
|
function embedDest(absPath) {
|
||||||
|
var base = decodeURIComponent(window.location.pathname);
|
||||||
|
if (base.slice(-1) !== '/') base += '/';
|
||||||
|
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedToken formats a Markdown image. A destination carrying spaces or
|
||||||
|
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
|
||||||
|
// piece — and the title slot holds the alignment keyword the renderer reads.
|
||||||
|
// Right is the renderer's default, so it is left off.
|
||||||
|
function embedToken(dest, caption, align) {
|
||||||
|
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
|
||||||
|
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
|
||||||
|
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
|
||||||
|
// caption and an alignment. Images render as a floated figure; anything
|
||||||
|
// else degrades to a link.
|
||||||
|
function insertEmbed() {
|
||||||
|
var sel = view.state.selection.main;
|
||||||
|
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||||
|
var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Embed — pick a file',
|
||||||
|
mode: 'file',
|
||||||
|
initialPath: here,
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
onSelect: function (absPath) {
|
||||||
|
var container = document.createElement('div');
|
||||||
|
|
||||||
|
var captionInput = document.createElement('input');
|
||||||
|
captionInput.type = 'text';
|
||||||
|
captionInput.className = 'input';
|
||||||
|
captionInput.placeholder = 'Caption (optional)';
|
||||||
|
captionInput.value = selectedText;
|
||||||
|
|
||||||
|
var alignSelect = document.createElement('select');
|
||||||
|
alignSelect.className = 'input';
|
||||||
|
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
|
||||||
|
.forEach(function (opt) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = opt[0];
|
||||||
|
o.textContent = opt[1];
|
||||||
|
alignSelect.appendChild(o);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(captionInput);
|
||||||
|
container.appendChild(alignSelect);
|
||||||
|
|
||||||
|
var handle = openModal({
|
||||||
|
title: 'Embed — caption?',
|
||||||
|
body: container,
|
||||||
|
confirm: {
|
||||||
|
label: 'INSERT',
|
||||||
|
onConfirm: function () {
|
||||||
|
handle.close();
|
||||||
|
insertAtCursor(embedToken(
|
||||||
|
embedDest(absPath),
|
||||||
|
captionInput.value.trim(),
|
||||||
|
alignSelect.value
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,68 @@
|
|||||||
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
// wikicomplete.js — the `[[` autocomplete source for CodeMirror.
|
||||||
//
|
//
|
||||||
// A level-by-level folder/file browser scoped to the path typed so far. It
|
// Wiki links are search queries, so the popup is the same typeahead the header
|
||||||
// fires only once the `[[` token's content begins with `/` (targets are
|
// search box uses: the text typed after `[[` goes to `/_search` and each match
|
||||||
// absolute; free-text search lives in the toolbar modal instead). The content
|
// is offered by name. Picking one replaces the query segment with that page's
|
||||||
// is split into a parent path (up to and including the last `/`) and a partial
|
// name — never a path — so what lands in the document is still a query. The
|
||||||
// segment (the text after it); the parent's children are fetched from the
|
// trailing `]]` and any `::display` tail are left untouched.
|
||||||
// existing `?tree=1` endpoint and filtered to names containing the partial
|
|
||||||
// (case-insensitive substring).
|
|
||||||
//
|
//
|
||||||
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
|
// Exposes window.WikiComplete.source for main.js to register via CM's
|
||||||
// deeper; picking a file inserts `<name>` and stops. Only the current segment
|
// autocompletion().
|
||||||
// is replaced, so the trailing `]]` stays put and the cursor parks before it,
|
|
||||||
// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
|
|
||||||
// for main.js to register via CM's autocompletion().
|
|
||||||
window.WikiComplete = (function () {
|
window.WikiComplete = (function () {
|
||||||
// treeURL builds the `?tree=1` request URL for an absolute parent path,
|
// Matches search-suggest.js so both surfaces fire at the same point.
|
||||||
// percent-encoding each segment. A leading/trailing slash is tolerated;
|
var MIN_QUERY_LEN = 2;
|
||||||
// root resolves to `/?tree=1`.
|
|
||||||
function treeURL(parent) {
|
|
||||||
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
||||||
if (trimmed === '') return '/?tree=1';
|
|
||||||
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
|
|
||||||
return '/' + enc + '/?tree=1';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchTree(parent) {
|
function fetchSuggestions(query) {
|
||||||
return fetch(treeURL(parent), {
|
return fetch('/_search?q=' + encodeURIComponent(query), {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
// A 404 means the parent folder doesn't exist (typo, or a path under
|
|
||||||
// a file) — treat it as "no completions", not an error.
|
|
||||||
if (r.status === 404) return null;
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
return r.json();
|
return r.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeApply builds the apply() for a chosen entry. It replaces only the
|
// queryEnd returns where the current query segment ends: the first `]`, the
|
||||||
// current segment ([from, to]); the trailing `]]` is untouched, so the
|
// `::` that starts the display text, or end of line. Replacing only up to
|
||||||
// cursor ends up parked before it. Folders append `/` and re-open the popup
|
// there keeps a re-edit from eating an existing alias or the closing `]]`.
|
||||||
// to drill into the next level; files terminate.
|
function queryEnd(context, pos) {
|
||||||
function makeApply(name, kind) {
|
var line = context.state.doc.lineAt(pos);
|
||||||
return function (view, completion, from, to) {
|
var to = pos;
|
||||||
var isFolder = kind === 'folder';
|
while (to < line.to) {
|
||||||
var insert = isFolder ? name + '/' : name;
|
var ch = context.state.sliceDoc(to, to + 1);
|
||||||
view.dispatch({
|
if (ch === ']') break;
|
||||||
changes: { from: from, to: to, insert: insert },
|
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
|
||||||
selection: { anchor: from + insert.length },
|
to++;
|
||||||
scrollIntoView: true,
|
}
|
||||||
});
|
return to;
|
||||||
if (isFolder) {
|
|
||||||
// Re-open after the transaction so the completion plugin sees
|
|
||||||
// the updated document (the next level's parent path).
|
|
||||||
setTimeout(function () { CM.startCompletion(view); }, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CM completion source. Activates when `[[` is followed by content that
|
// CM completion source. Activates on `[[` followed by at least
|
||||||
// begins with `/`. The content is split at its last `/` into a parent path
|
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
|
||||||
// and a partial segment; the parent's children are fetched, filtered to
|
// gets nothing.
|
||||||
// names containing the partial (case-insensitive substring), and offered
|
|
||||||
// with name-only labels.
|
|
||||||
function source(context) {
|
function source(context) {
|
||||||
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
||||||
if (content[0] !== '/') return null;
|
if (content.indexOf('::') !== -1) return null;
|
||||||
|
var query = content.trim();
|
||||||
|
if (query.length < MIN_QUERY_LEN) return null;
|
||||||
|
|
||||||
var lastSlash = content.lastIndexOf('/');
|
var from = match.from + 2;
|
||||||
var parent = content.slice(0, lastSlash + 1);
|
var to = queryEnd(context, context.pos);
|
||||||
var partial = content.slice(lastSlash + 1);
|
|
||||||
|
|
||||||
// Replace only the current segment: from the start of the partial up to
|
|
||||||
// the next `/` or `]` (or end of line). This narrows re-edits inside an
|
|
||||||
// existing `[[…]]` so drilling doesn't duplicate trailing text.
|
|
||||||
var from = match.from + 2 + lastSlash + 1;
|
|
||||||
var line = context.state.doc.lineAt(context.pos);
|
|
||||||
var to = context.pos;
|
|
||||||
while (to < line.to) {
|
|
||||||
var ch = context.state.sliceDoc(to, to + 1);
|
|
||||||
if (ch === '/' || ch === ']') break;
|
|
||||||
to++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
if (context.aborted) { resolve(null); return; }
|
if (context.aborted) { resolve(null); return; }
|
||||||
fetchTree(parent).then(function (resp) {
|
fetchSuggestions(query).then(function (resp) {
|
||||||
if (context.aborted || !resp) { resolve(null); return; }
|
if (context.aborted) { resolve(null); return; }
|
||||||
var needle = partial.toLowerCase();
|
var options = (resp.results || []).map(function (r) {
|
||||||
var options = (resp.entries || []).reduce(function (acc, e) {
|
return { label: r.name, detail: '/' + r.path, apply: r.name };
|
||||||
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
|
});
|
||||||
return acc;
|
// filter:false — the server already ranked these fuzzily, and
|
||||||
}
|
// CM's own prefix filter would drop the near-misses (typos,
|
||||||
acc.push({
|
// token reorderings) that make the fuzzy search worth having.
|
||||||
label: e.name,
|
// No validFor either: every keystroke re-queries from scratch.
|
||||||
type: e.kind === 'folder' ? 'folder' : 'file',
|
resolve({ from: from, to: to, options: options, filter: false });
|
||||||
apply: makeApply(e.name, e.kind),
|
|
||||||
});
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
// No validFor: the source re-runs on each keystroke, so every
|
|
||||||
// edit (more chars, backspace, or a `/` that drills into the
|
|
||||||
// next folder) re-fetches and re-filters from scratch.
|
|
||||||
resolve({ from: from, to: to, options: options });
|
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
resolve(null);
|
resolve(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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="/_/search-suggest.js" defer></script>
|
||||||
<script src="/_/tree-picker.js"></script>
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="/_/companion.js" defer></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}}
|
<script src="/_/tree-sidebar.js" defer></script>{{end}}
|
||||||
{{block "headScripts" .}}{{end}}
|
{{block "headScripts" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
@@ -24,13 +24,13 @@
|
|||||||
<nav class="breadcrumb row">
|
<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>
|
<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>
|
</nav>
|
||||||
{{if not .EditMode}}
|
{{if .ShowPageChrome}}
|
||||||
<form class="search-form" action="/" method="get">
|
<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" />
|
<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>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="header-actions row">
|
<div class="header-actions row">
|
||||||
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
|
{{if not .ShowPageChrome}}{{block "headerActions" .}}{{end}}{{else}}
|
||||||
<span class="dropdown actions">
|
<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>
|
<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">
|
<div class="dropdown-menu align-right actions-menu">
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
{{if .ShowPageChrome}}<aside class="tree-sidebar col">
|
||||||
<div class="tree-scroll"></div>
|
<div class="tree-scroll"></div>
|
||||||
</aside>{{end}}
|
</aside>{{end}}
|
||||||
<div class="center">
|
<div class="center">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{block "extras" .}}{{end}}
|
{{block "extras" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+1
-16
@@ -129,23 +129,9 @@ function movePage() {
|
|||||||
input.placeholder = 'Page name';
|
input.placeholder = 'Page name';
|
||||||
input.value = currentName;
|
input.value = currentName;
|
||||||
|
|
||||||
var linksCheckbox = document.createElement('input');
|
|
||||||
linksCheckbox.type = 'checkbox';
|
|
||||||
linksCheckbox.id = 'move-update-links';
|
|
||||||
|
|
||||||
var linksLabel = document.createElement('label');
|
|
||||||
linksLabel.htmlFor = linksCheckbox.id;
|
|
||||||
linksLabel.className = 'row';
|
|
||||||
linksLabel.appendChild(linksCheckbox);
|
|
||||||
linksLabel.appendChild(document.createTextNode('Update links'));
|
|
||||||
|
|
||||||
var body = document.createDocumentFragment();
|
|
||||||
body.appendChild(input);
|
|
||||||
body.appendChild(linksLabel);
|
|
||||||
|
|
||||||
openModal({
|
openModal({
|
||||||
title: 'Move — new name?',
|
title: 'Move — new name?',
|
||||||
body: body,
|
body: input,
|
||||||
confirm: {
|
confirm: {
|
||||||
label: 'MOVE',
|
label: 'MOVE',
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
@@ -154,7 +140,6 @@ function movePage() {
|
|||||||
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||||
var action = window.location.pathname + '?move=' +
|
var action = window.location.pathname + '?move=' +
|
||||||
encodeURIComponent(dest);
|
encodeURIComponent(dest);
|
||||||
if (linksCheckbox.checked) action += '&links=1';
|
|
||||||
var target = encodePickedPath(dest) + '/';
|
var target = encodePickedPath(dest) + '/';
|
||||||
closeModal();
|
closeModal();
|
||||||
submitMove(action, target);
|
submitMove(action, target);
|
||||||
|
|||||||
@@ -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}}
|
{{define "headerActions"}}{{if .CanEdit}}
|
||||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
|
<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>
|
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
|
<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}}
|
{{end}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{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}}
|
{{if .Content}}
|
||||||
<div class="content">{{.Content}}</div>
|
<div class="content">{{.Content}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+327
-2
@@ -26,6 +26,17 @@
|
|||||||
--danger-hover: #d03467;
|
--danger-hover: #d03467;
|
||||||
--overlay: rgba(0, 0, 0, 0.55);
|
--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: 1px solid var(--secondary);
|
||||||
--border-dashed: 1px dashed var(--secondary);
|
--border-dashed: 1px dashed var(--secondary);
|
||||||
--border-guide: 1px solid var(--bg-panel-hover);
|
--border-guide: 1px solid var(--bg-panel-hover);
|
||||||
@@ -75,8 +86,6 @@ a { color: var(--text); text-decoration: none; }
|
|||||||
a:hover { color: var(--link-hover); }
|
a:hover { color: var(--link-hover); }
|
||||||
.content a { color: var(--link); }
|
.content a { color: var(--link); }
|
||||||
.content a:hover { color: var(--link-hover); }
|
.content a:hover { color: var(--link-hover); }
|
||||||
.content a.broken { color: var(--link-hover); text-decoration: line-through; }
|
|
||||||
.content a.broken:hover { color: var(--link-hover); }
|
|
||||||
|
|
||||||
hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
|
hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
|
||||||
|
|
||||||
@@ -891,3 +900,319 @@ aside.sidebar:empty { display: none; }
|
|||||||
.list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; }
|
.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);}
|
.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))
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
@@ -14,105 +14,95 @@ import (
|
|||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
|
// Embeds are ordinary Markdown images: .
|
||||||
// The inner blob forbids brackets and newlines but allows ':' so the whole
|
//
|
||||||
// target::caption::align payload travels as one capture — the caption and
|
// Destinations are relative to the page they sit on. Pages always render at a
|
||||||
// alignment tail is split apart in Go (parseEmbedFields). This mirrors
|
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
|
||||||
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
|
// resolves the destination and nothing here builds a URL or touches the
|
||||||
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
|
// filesystem. Goldmark's own image parser produces the nodes; only rendering
|
||||||
|
// is overridden. The title slot carries the float alignment.
|
||||||
const (
|
const (
|
||||||
alignLeft = "left"
|
alignLeft = "left"
|
||||||
alignRight = "right"
|
alignRight = "right"
|
||||||
alignCenter = "center"
|
alignCenter = "center"
|
||||||
|
|
||||||
|
// embedThumbW is the width embeds request through the ?w= form of the
|
||||||
|
// file's own URL, which keeps the <img src> relative.
|
||||||
|
embedThumbW = 300
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiEmbedNode is the AST node produced by wikiEmbedParser.
|
// embedAlign maps an image title to its float class. A title that is not an
|
||||||
type wikiEmbedNode struct {
|
// alignment keyword is ignored and the embed floats right.
|
||||||
ast.BaseInline
|
func embedAlign(title []byte) string {
|
||||||
Target string
|
switch strings.TrimSpace(string(title)) {
|
||||||
Caption string
|
|
||||||
Align string
|
|
||||||
}
|
|
||||||
|
|
||||||
var kindWikiEmbed = ast.NewNodeKind("WikiEmbed")
|
|
||||||
|
|
||||||
func (n *wikiEmbedNode) Kind() ast.NodeKind { return kindWikiEmbed }
|
|
||||||
|
|
||||||
func (n *wikiEmbedNode) Dump(source []byte, level int) {
|
|
||||||
ast.DumpHelper(n, source, level, map[string]string{
|
|
||||||
"Target": n.Target,
|
|
||||||
"Caption": n.Caption,
|
|
||||||
"Align": n.Align,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// alignKeyword reports whether s (trimmed) is one of the alignment keywords.
|
|
||||||
func alignKeyword(s string) (string, bool) {
|
|
||||||
switch strings.TrimSpace(s) {
|
|
||||||
case alignLeft:
|
case alignLeft:
|
||||||
return alignLeft, true
|
return alignLeft
|
||||||
case alignRight:
|
|
||||||
return alignRight, true
|
|
||||||
case alignCenter:
|
case alignCenter:
|
||||||
return alignCenter, true
|
return alignCenter
|
||||||
}
|
}
|
||||||
return "", false
|
return alignRight
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
|
// embedIsImage reports whether a destination names an image, i.e. whether it
|
||||||
// alignment per the embed syntax rules. The target is the field before the
|
// renders as a <figure> rather than degrading to a link. Extension-only — no
|
||||||
// first "::". The trailing "::"-fields are interpreted as:
|
// stat, so a destination gone stale simply 404s when clicked.
|
||||||
// - none: no caption, default alignment (right)
|
func embedIsImage(dest []byte) bool {
|
||||||
// - one keyword field: that alignment, no caption
|
return isImageFile(path.Base(string(dest)))
|
||||||
// - one non-keyword field: that caption, default alignment
|
}
|
||||||
// - two or more: the last field is the alignment slot (default when it is not
|
|
||||||
// a keyword) and the earlier fields re-join with "::" as the caption, so a
|
type embedRenderer struct{}
|
||||||
// caption may contain a literal "::" as long as an alignment field trails it.
|
|
||||||
func parseEmbedFields(inner string) (target, caption, align string) {
|
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
fields := strings.Split(inner, "::")
|
reg.Register(ast.KindImage, r.render)
|
||||||
target = strings.TrimSpace(fields[0])
|
}
|
||||||
tail := fields[1:]
|
|
||||||
align = alignRight
|
// render writes a <figure> holding a thumbnail that links to the full file,
|
||||||
switch len(tail) {
|
// with the image's inline children as <figcaption>. Children are rendered by
|
||||||
case 0:
|
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
|
||||||
// target only
|
// emphasis or a link. A non-image destination renders as a plain anchor
|
||||||
case 1:
|
// labelled with the caption, or with the file name when there is none.
|
||||||
if kw, ok := alignKeyword(tail[0]); ok {
|
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
align = kw
|
n := node.(*ast.Image)
|
||||||
|
name := path.Base(string(n.Destination))
|
||||||
|
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
|
||||||
|
captioned := n.FirstChild() != nil
|
||||||
|
|
||||||
|
if !embedIsImage(n.Destination) {
|
||||||
|
if entering {
|
||||||
|
w.WriteString(`<a href="`)
|
||||||
|
w.Write(dest)
|
||||||
|
w.WriteString(`">`)
|
||||||
|
if !captioned {
|
||||||
|
w.Write(util.EscapeHTML([]byte(name)))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
caption = strings.TrimSpace(tail[0])
|
w.WriteString(`</a>`)
|
||||||
}
|
}
|
||||||
default:
|
return ast.WalkContinue, nil
|
||||||
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
|
}
|
||||||
align = kw
|
|
||||||
|
if entering {
|
||||||
|
w.WriteString(`<figure class="embed embed-`)
|
||||||
|
w.WriteString(embedAlign(n.Title))
|
||||||
|
w.WriteString(`"><a class="embed-link" href="`)
|
||||||
|
w.Write(dest)
|
||||||
|
w.WriteString(`"><img src="`)
|
||||||
|
w.Write(dest)
|
||||||
|
w.WriteString(`?w=`)
|
||||||
|
w.WriteString(strconv.Itoa(embedThumbW))
|
||||||
|
w.WriteString(`" alt="`)
|
||||||
|
w.Write(util.EscapeHTML([]byte(name)))
|
||||||
|
w.WriteString(`"></a>`)
|
||||||
|
if captioned {
|
||||||
|
w.WriteString(`<figcaption>`)
|
||||||
}
|
}
|
||||||
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
return target, caption, align
|
if captioned {
|
||||||
}
|
w.WriteString(`</figcaption>`)
|
||||||
|
|
||||||
type wikiEmbedParser struct{}
|
|
||||||
|
|
||||||
func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
|
|
||||||
|
|
||||||
func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
|
||||||
line, _ := block.PeekLine()
|
|
||||||
// Require the exact "")
|
|
||||||
// falls through to goldmark's default image parser at priority 200.
|
|
||||||
if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
m := wikiEmbedRe.FindSubmatchIndex(line)
|
w.WriteString(`</figure>`)
|
||||||
if m == nil {
|
return ast.WalkContinue, nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
|
|
||||||
if !isValidWikiTarget([]byte(target)) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
block.Advance(m[1])
|
|
||||||
return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
|
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
|
||||||
@@ -122,7 +112,7 @@ func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Co
|
|||||||
// and breaks the float layout once several embeds share a page. Dissolving the
|
// and breaks the float layout once several embeds share a page. Dissolving the
|
||||||
// wrapping paragraph makes each embed render as a clean block sibling with no
|
// wrapping paragraph makes each embed render as a clean block sibling with no
|
||||||
// stray <p>.
|
// stray <p>.
|
||||||
type embedBlockTransformer struct{ root string }
|
type embedBlockTransformer struct{}
|
||||||
|
|
||||||
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||||
source := reader.Source()
|
source := reader.Source()
|
||||||
@@ -144,7 +134,7 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
|
|||||||
// block sibling ahead of the (soon removed) paragraph, then drop p.
|
// block sibling ahead of the (soon removed) paragraph, then drop p.
|
||||||
for c := p.FirstChild(); c != nil; {
|
for c := p.FirstChild(); c != nil; {
|
||||||
next := c.NextSibling()
|
next := c.NextSibling()
|
||||||
if _, ok := c.(*wikiEmbedNode); ok {
|
if _, ok := c.(*ast.Image); ok {
|
||||||
parent.InsertBefore(parent, p, c)
|
parent.InsertBefore(parent, p, c)
|
||||||
}
|
}
|
||||||
c = next
|
c = next
|
||||||
@@ -154,16 +144,16 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
|
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
|
||||||
// embed and nothing else visible — only such embeds and whitespace/line-break
|
// image and nothing else visible — only such images and whitespace/line-break
|
||||||
// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
|
// text. Only these are safe to dissolve: a paragraph carrying prose, or an
|
||||||
// that degrades to an inline link (missing / non-image target), stays wrapped so
|
// image that degrades to an inline link (non-image destination), stays wrapped
|
||||||
// the fallback anchor keeps its paragraph.
|
// so the fallback anchor keeps its paragraph.
|
||||||
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
|
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
|
||||||
hasFigure := false
|
hasFigure := false
|
||||||
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
|
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
|
||||||
switch n := c.(type) {
|
switch n := c.(type) {
|
||||||
case *wikiEmbedNode:
|
case *ast.Image:
|
||||||
if !embedIsImage(t.root, n.Target) {
|
if !embedIsImage(n.Destination) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
hasFigure = true
|
hasFigure = true
|
||||||
@@ -178,77 +168,19 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
|
|||||||
return hasFigure
|
return hasFigure
|
||||||
}
|
}
|
||||||
|
|
||||||
// embedIsImage reports whether target resolves to an existing image file, i.e.
|
type embedExt struct{}
|
||||||
// the embed will render as a <figure> rather than degrade to a link fallback.
|
|
||||||
func embedIsImage(root, target string) bool {
|
|
||||||
name := path.Base(normalizeWikiTarget(target))
|
|
||||||
return wikiTargetExists(root, target) && isImageFile(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
type wikiEmbedRenderer struct {
|
// newEmbedExt returns a goldmark extension that renders Markdown images as
|
||||||
root string
|
// wiki embeds. It adds no parser of its own: goldmark parses the images, and
|
||||||
}
|
// the renderer registered at 500 wins over the default HTML renderer
|
||||||
|
// (registered at 1000 — lowest priority number is applied last and wins).
|
||||||
|
func newEmbedExt() goldmark.Extender { return &embedExt{} }
|
||||||
|
|
||||||
func (r *wikiEmbedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
func (e *embedExt) Extend(m goldmark.Markdown) {
|
||||||
reg.Register(kindWikiEmbed, r.render)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *wikiEmbedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
||||||
if !entering {
|
|
||||||
return ast.WalkContinue, nil
|
|
||||||
}
|
|
||||||
n := node.(*wikiEmbedNode)
|
|
||||||
|
|
||||||
// Embed only when the target exists and is an image we can thumbnail.
|
|
||||||
// Anything else — a missing target, or an existing non-image file — degrades
|
|
||||||
// to the same anchor a plain [[wikilink]] would render (broken or working).
|
|
||||||
if !embedIsImage(r.root, n.Target) {
|
|
||||||
writeWikiLink(w, r.root, n.Target, "")
|
|
||||||
return ast.WalkContinue, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fileHref := wikiFileHref(n.Target)
|
|
||||||
alt := n.Caption
|
|
||||||
if alt == "" {
|
|
||||||
alt = path.Base(normalizeWikiTarget(n.Target))
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteString(`<figure class="embed embed-`)
|
|
||||||
w.WriteString(n.Align)
|
|
||||||
w.WriteString(`"><a class="embed-link" href="`)
|
|
||||||
w.WriteString(fileHref)
|
|
||||||
w.WriteString(`"><img src="`)
|
|
||||||
w.WriteString(thumbURL(fileHref, 300))
|
|
||||||
w.WriteString(`" alt="`)
|
|
||||||
w.Write(util.EscapeHTML([]byte(alt)))
|
|
||||||
w.WriteString(`"></a>`)
|
|
||||||
if n.Caption != "" {
|
|
||||||
w.WriteString(`<figcaption>`)
|
|
||||||
w.Write(util.EscapeHTML([]byte(n.Caption)))
|
|
||||||
w.WriteString(`</figcaption>`)
|
|
||||||
}
|
|
||||||
w.WriteString(`</figure>`)
|
|
||||||
return ast.WalkContinue, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type wikiEmbedExt struct{ root string }
|
|
||||||
|
|
||||||
// newWikiEmbedExt returns a goldmark extension that turns ![[...]] tokens into
|
|
||||||
// image embeds resolved against root.
|
|
||||||
func newWikiEmbedExt(root string) goldmark.Extender {
|
|
||||||
return &wikiEmbedExt{root: root}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *wikiEmbedExt) Extend(m goldmark.Markdown) {
|
|
||||||
// Priority 199 — one higher than the default image parser (200) so ![[...]]
|
|
||||||
// is consumed as an embed before the default `!` parser sees it.
|
|
||||||
m.Parser().AddOptions(parser.WithInlineParsers(
|
|
||||||
util.Prioritized(&wikiEmbedParser{}, 199),
|
|
||||||
))
|
|
||||||
m.Parser().AddOptions(parser.WithASTTransformers(
|
m.Parser().AddOptions(parser.WithASTTransformers(
|
||||||
util.Prioritized(embedBlockTransformer{root: e.root}, 100),
|
util.Prioritized(embedBlockTransformer{}, 100),
|
||||||
))
|
))
|
||||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||||
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
|
util.Prioritized(&embedRenderer{}, 500),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -47,6 +48,7 @@ var (
|
|||||||
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
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"))
|
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"))
|
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.
|
// specialPage is the result returned by a pageTypeHandler.
|
||||||
@@ -105,7 +107,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
initMarkdown(root)
|
initMarkdown()
|
||||||
|
|
||||||
authKey, err := loadOrCreateAuthKey(root)
|
authKey, err := loadOrCreateAuthKey(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,6 +126,7 @@ func main() {
|
|||||||
http.HandleFunc("/_logout", h.handleLogout)
|
http.HandleFunc("/_logout", h.handleLogout)
|
||||||
http.HandleFunc("/_reindex", h.handleReindex)
|
http.HandleFunc("/_reindex", h.handleReindex)
|
||||||
http.HandleFunc("/_search", h.handleSearchSuggest)
|
http.HandleFunc("/_search", h.handleSearchSuggest)
|
||||||
|
http.HandleFunc("/_canvas/render", h.handleCanvasRender)
|
||||||
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
||||||
http.Handle("/", h)
|
http.Handle("/", h)
|
||||||
|
|
||||||
@@ -212,6 +215,16 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
info, err := os.Stat(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -220,7 +233,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// POSTs must not be redirected — the form action has no trailing
|
// POSTs must not be redirected — the form action has no trailing
|
||||||
// slash (path.Clean strips it) and the content would be lost.
|
// slash (path.Clean strips it) and the content would be lost.
|
||||||
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
|
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
|
||||||
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.serveDir(w, r, urlPath, fsPath)
|
h.serveDir(w, r, urlPath, fsPath)
|
||||||
@@ -232,14 +245,39 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
if urlPath != "/" {
|
if urlPath != "/" {
|
||||||
|
// Pages must live at a slash-terminated URL: embed destinations are
|
||||||
|
// relative, so "/Trips/Japan" would resolve them against "/Trips/".
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
}
|
||||||
urlPath += "/"
|
urlPath += "/"
|
||||||
}
|
}
|
||||||
h.serveDir(w, r, urlPath, fsPath)
|
h.serveDir(w, r, urlPath, fsPath)
|
||||||
} else {
|
} else {
|
||||||
|
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so
|
||||||
|
// their <img src> can stay relative to the page; /_thumb/<abs path>
|
||||||
|
// stays for listings, which have absolute paths on hand anyway. A file
|
||||||
|
// with no thumbnailer ignores the parameter and serves normally.
|
||||||
|
if r.URL.Query().Has("w") && hasThumbnail(fsPath) {
|
||||||
|
h.serveThumbnail(w, r, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.ServeFile(w, r, fsPath)
|
http.ServeFile(w, r, fsPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slashedURL returns u's path with a trailing slash appended and the query
|
||||||
|
// string intact — the canonical page form that relative destinations resolve
|
||||||
|
// against.
|
||||||
|
func slashedURL(u *url.URL) string {
|
||||||
|
target := u.Path + "/"
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
target += "?" + u.RawQuery
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
_, editMode := r.URL.Query()["edit"]
|
_, editMode := r.URL.Query()["edit"]
|
||||||
|
|
||||||
@@ -248,6 +286,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
return
|
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 {
|
for _, ph := range pageTypeHandlers {
|
||||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
@@ -290,6 +341,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
rendered = renderMarkdown(rawMD)
|
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()
|
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
||||||
var entries []entry
|
var entries []entry
|
||||||
if !editMode && (special == nil || !special.SuppressListing) {
|
if !editMode && (special == nil || !special.SuppressListing) {
|
||||||
@@ -353,6 +412,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
SidebarWidget: sidebarWidget,
|
SidebarWidget: sidebarWidget,
|
||||||
SuppressTOC: suppressTOC,
|
SuppressTOC: suppressTOC,
|
||||||
|
Canvases: canvases,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -368,12 +428,18 @@ 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) {
|
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
query := r.URL.Query()
|
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") {
|
if query.Has("delete") {
|
||||||
h.handleDelete(w, r, urlPath, fsPath)
|
h.handleDelete(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := query["move"]; ok {
|
if _, ok := query["move"]; ok {
|
||||||
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"), query.Has("merge"))
|
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if query.Has("toggle") {
|
if query.Has("toggle") {
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When
|
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
|
||||||
// updateLinks is true it also rewrites every [[...]] wiki link across the
|
|
||||||
// tree that targets the old path or any descendant; rewritten files are held
|
|
||||||
// in memory for rollback.
|
|
||||||
//
|
//
|
||||||
// If the destination folder already exists it is normally a hard conflict.
|
// If the destination folder already exists it is normally a hard conflict.
|
||||||
// The one exception is a merge: when the source carries a page (index.md) and
|
// The one exception is a merge: when the source carries a page (index.md) and
|
||||||
@@ -21,7 +16,7 @@ import (
|
|||||||
// container without clobbering anything. That merge only proceeds when merge
|
// container without clobbering anything. That merge only proceeds when merge
|
||||||
// is true; otherwise the client is told a merge is available so it can ask the
|
// is true; otherwise the client is told a merge is available so it can ask the
|
||||||
// user to confirm.
|
// user to confirm.
|
||||||
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) {
|
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
|
||||||
oldPath := normalizeMovePath(srcURL)
|
oldPath := normalizeMovePath(srcURL)
|
||||||
if oldPath == "/" {
|
if oldPath == "/" {
|
||||||
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
|
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
|
||||||
@@ -74,69 +69,32 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
merging = true
|
merging = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: optionally walk the tree and rewrite every index.md that
|
// Phase 1: create intermediate parent folders for the destination.
|
||||||
// references the moved path. Keep the pre-rewrite bytes in memory so we
|
|
||||||
// can revert on failure. The walker only reads directory listings and
|
|
||||||
// files literally named index.md; hidden directories are pruned. A cheap
|
|
||||||
// substring check skips parsing files that cannot contain a relevant
|
|
||||||
// link.
|
|
||||||
rewritten := map[string][]byte{}
|
|
||||||
if updateLinks {
|
|
||||||
needle := []byte("[[" + oldPath)
|
|
||||||
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
|
|
||||||
orig, err := os.ReadFile(fsPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.Contains(orig, needle) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
updated, changed := rewriteWikiLinks(orig, oldPath, newPath)
|
|
||||||
if !changed {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := writeFileAtomic(fsPath, updated, 0644); err != nil {
|
|
||||||
return fmt.Errorf("write %s: %w", fsPath, err)
|
|
||||||
}
|
|
||||||
rewritten[fsPath] = orig
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if walkErr != nil {
|
|
||||||
rollbackRewrites(rewritten)
|
|
||||||
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: create intermediate parent folders for the destination.
|
|
||||||
if parent := filepath.Dir(dstFsPath); parent != "" {
|
if parent := filepath.Dir(dstFsPath); parent != "" {
|
||||||
if err := os.MkdirAll(parent, 0755); err != nil {
|
if err := os.MkdirAll(parent, 0755); err != nil {
|
||||||
rollbackRewrites(rewritten)
|
|
||||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: move the source into place. A plain move renames the whole
|
// Phase 2: move the source into place. A plain move renames the whole
|
||||||
// folder; a merge moves the source's entries into the existing
|
// folder; a merge moves the source's entries into the existing
|
||||||
// destination and drops the emptied source.
|
// destination and drops the emptied source.
|
||||||
if merging {
|
if merging {
|
||||||
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
|
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
|
||||||
rollbackRewrites(rewritten)
|
|
||||||
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||||
} else {
|
} else {
|
||||||
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
|
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
|
||||||
rollbackRewrites(rewritten)
|
|
||||||
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
|
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// canMergeMove reports whether moving onto an existing destination should
|
// canMergeMove reports whether moving onto an existing destination should
|
||||||
@@ -248,77 +206,6 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
|
|||||||
return "/" + trimmed, nil
|
return "/" + trimmed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
|
|
||||||
// [[target::display]] whose target equals oldPath or begins with oldPath+"/"
|
|
||||||
// has its target rewritten to the corresponding position under newPath.
|
|
||||||
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
|
||||||
changed := false
|
|
||||||
out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte {
|
|
||||||
parts := wikiLinkPattern.FindSubmatch(match)
|
|
||||||
if parts == nil {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
target := strings.TrimSpace(string(parts[1]))
|
|
||||||
normTarget := normalizeMovePath(target)
|
|
||||||
var newTarget string
|
|
||||||
switch {
|
|
||||||
case normTarget == oldPath:
|
|
||||||
newTarget = newPath
|
|
||||||
case strings.HasPrefix(normTarget, oldPath+"/"):
|
|
||||||
newTarget = newPath + strings.TrimPrefix(normTarget, oldPath)
|
|
||||||
default:
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
suffix := ""
|
|
||||||
if len(parts[2]) > 0 {
|
|
||||||
suffix = "::" + string(parts[2])
|
|
||||||
}
|
|
||||||
return []byte("[[" + newTarget + suffix + "]]")
|
|
||||||
})
|
|
||||||
return out, changed
|
|
||||||
}
|
|
||||||
|
|
||||||
// rollbackRewrites restores the given files to their pre-rewrite contents.
|
|
||||||
// Errors are logged; best-effort since we're already in a failure path.
|
|
||||||
func rollbackRewrites(rewritten map[string][]byte) {
|
|
||||||
for path, orig := range rewritten {
|
|
||||||
if err := writeFileAtomic(path, orig, 0644); err != nil {
|
|
||||||
log.Printf("rollback %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkIndexFiles visits every `index.md` under root, skipping hidden
|
|
||||||
// directories (names beginning with `.`). Unlike filepath.WalkDir this does
|
|
||||||
// not stat each regular file — on spinning disks that saves the bulk of the
|
|
||||||
// traversal cost when folders contain many non-page files (photos, archives).
|
|
||||||
func walkIndexFiles(root string, visit func(fsPath string) error) error {
|
|
||||||
entries, err := os.ReadDir(root)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
name := e.Name()
|
|
||||||
if strings.HasPrefix(name, ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
full := filepath.Join(root, name)
|
|
||||||
if e.IsDir() {
|
|
||||||
if err := walkIndexFiles(full, visit); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if name == "index.md" {
|
|
||||||
if err := visit(full); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFileAtomic writes data to a temp file in the same directory as path
|
// writeFileAtomic writes data to a temp file in the same directory as path
|
||||||
// and renames it into place so readers never observe a partial file.
|
// and renames it into place so readers never observe a partial file.
|
||||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import (
|
|||||||
|
|
||||||
var md goldmark.Markdown
|
var md goldmark.Markdown
|
||||||
|
|
||||||
// initMarkdown builds the package-level goldmark instance. Called once from
|
// initMarkdown builds the package-level goldmark instance. Neither extension
|
||||||
// main after the wiki root is known so the wiki-link extension can resolve
|
// resolves anything against the filesystem — wiki links are search queries and
|
||||||
// targets against the filesystem.
|
// embed destinations are relative to the page — so this needs no wiki root.
|
||||||
func initMarkdown(root string) {
|
func initMarkdown() {
|
||||||
md = goldmark.New(
|
md = goldmark.New(
|
||||||
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root)),
|
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
|
||||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||||
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
|
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
|
||||||
)
|
)
|
||||||
@@ -64,6 +64,39 @@ type pageData struct {
|
|||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
SuppressTOC bool
|
SuppressTOC bool
|
||||||
RenderMS int64
|
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
|
// Allowed values for the listing view settings. Unknown values in the file or
|
||||||
@@ -130,6 +163,7 @@ var (
|
|||||||
iconAudio = readIcon("audio")
|
iconAudio = readIcon("audio")
|
||||||
iconArchive = readIcon("archive")
|
iconArchive = readIcon("archive")
|
||||||
iconGeneric = readIcon("generic")
|
iconGeneric = readIcon("generic")
|
||||||
|
iconCanvas = readIcon("canvas")
|
||||||
)
|
)
|
||||||
|
|
||||||
// renderMarkdown converts raw markdown to trusted HTML.
|
// renderMarkdown converts raw markdown to trusted HTML.
|
||||||
@@ -211,6 +245,9 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
|||||||
if name == "index.md" {
|
if name == "index.md" {
|
||||||
continue // rendered above, don't list it
|
continue // rendered above, don't list it
|
||||||
}
|
}
|
||||||
|
if isCanvasFile(name) {
|
||||||
|
continue // surfaced as a view-switcher tab above the content
|
||||||
|
}
|
||||||
f := entry{
|
f := entry{
|
||||||
Icon: fileIcon(name),
|
Icon: fileIcon(name),
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -300,6 +337,8 @@ func readIcon(name string) template.HTML {
|
|||||||
func fileIcon(name string) template.HTML {
|
func fileIcon(name string) template.HTML {
|
||||||
ext := strings.ToLower(path.Ext(name))
|
ext := strings.ToLower(path.Ext(name))
|
||||||
switch ext {
|
switch ext {
|
||||||
|
case ".canvas":
|
||||||
|
return iconCanvas
|
||||||
case ".md", ".pdf":
|
case ".md", ".pdf":
|
||||||
return iconDoc
|
return iconDoc
|
||||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ type searchPageData struct {
|
|||||||
RenderMS int64
|
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
|
// indexEntry is the shared scoreable core of both folder and file index
|
||||||
// entries: a forward-slash relative path plus its pre-tokenized basename so
|
// entries: a forward-slash relative path plus its pre-tokenized basename so
|
||||||
// the per-query scoring loop avoids redoing the lowercasing and tokenization
|
// 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 {
|
if level == 0 {
|
||||||
return start + 1
|
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 {
|
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,9 +96,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad path", http.StatusBadRequest)
|
http.Error(w, "bad path", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cleanPath := path.Clean(decoded)
|
h.serveThumbnail(w, r, filepath.Join(h.root, filepath.FromSlash(path.Clean(decoded))))
|
||||||
|
}
|
||||||
|
|
||||||
srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
|
// serveThumbnail generates (or serves from cache) a thumbnail of the file at
|
||||||
|
// srcFS, sized by the ?w= query. Both entry points land here: the /_thumb
|
||||||
|
// route used by listings and the diary, and the ?w= query on a file's own URL,
|
||||||
|
// which is the form embeds use so their <img src> stays relative to the page.
|
||||||
|
func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS string) {
|
||||||
rel, err := filepath.Rel(h.root, srcFS)
|
rel, err := filepath.Rel(h.root, srcFS)
|
||||||
if err != nil || strings.HasPrefix(rel, "..") {
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
|||||||
+48
-116
@@ -3,8 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,20 +14,17 @@ import (
|
|||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
|
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
|
||||||
// of the current inline reader. Target and display forbid newlines and
|
// the current inline reader. Query and display forbid newlines and brackets;
|
||||||
// brackets; the target is non-greedy so the first `::` separates target from
|
// the query is non-greedy so the first `::` separates query from display when
|
||||||
// display when both are present.
|
// both are present.
|
||||||
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
||||||
|
|
||||||
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
|
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
|
||||||
// Used by the move-endpoint rewriter; not by the goldmark parser.
|
// normalized by wikiSearchQuery.
|
||||||
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
|
||||||
|
|
||||||
// wikiLinkNode is the AST node produced by wikiLinkParser.
|
|
||||||
type wikiLinkNode struct {
|
type wikiLinkNode struct {
|
||||||
ast.BaseInline
|
ast.BaseInline
|
||||||
Target []byte
|
Query []byte
|
||||||
Display []byte
|
Display []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,27 +34,32 @@ func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
|
|||||||
|
|
||||||
func (n *wikiLinkNode) Dump(source []byte, level int) {
|
func (n *wikiLinkNode) Dump(source []byte, level int) {
|
||||||
ast.DumpHelper(n, source, level, map[string]string{
|
ast.DumpHelper(n, source, level, map[string]string{
|
||||||
"Target": string(n.Target),
|
"Query": string(n.Query),
|
||||||
"Display": string(n.Display),
|
"Display": string(n.Display),
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidWikiTarget rejects targets that are not absolute or that contain
|
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
|
||||||
// traversal / empty segments. Matches the validation used by the move endpoint.
|
// A leading `/` marks a link written under the old path-based syntax; those
|
||||||
func isValidWikiTarget(target []byte) bool {
|
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
|
||||||
if len(target) == 0 || target[0] != '/' {
|
// existing content keeps resolving without a rewrite. Everything else is free
|
||||||
return false
|
// text and passes through with only surrounding space trimmed.
|
||||||
|
func wikiSearchQuery(raw string) string {
|
||||||
|
q := strings.TrimSpace(raw)
|
||||||
|
if !strings.HasPrefix(q, "/") {
|
||||||
|
return q
|
||||||
}
|
}
|
||||||
trimmed := strings.Trim(string(target), "/")
|
trimmed := strings.Trim(q, "/")
|
||||||
if trimmed == "" {
|
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
|
||||||
return true // root link
|
return strings.TrimSpace(trimmed[i+1:])
|
||||||
}
|
}
|
||||||
for _, seg := range strings.Split(trimmed, "/") {
|
return trimmed
|
||||||
if seg == "" || seg == "." || seg == ".." {
|
}
|
||||||
return false
|
|
||||||
}
|
// wikiSearchHref builds the search-page URL for a query. Escaping leaves only
|
||||||
}
|
// URL-safe bytes, so the result needs no further HTML escaping in an href.
|
||||||
return true
|
func wikiSearchHref(query string) string {
|
||||||
|
return "/?q=" + url.QueryEscape(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
type wikiLinkParser struct{}
|
type wikiLinkParser struct{}
|
||||||
@@ -75,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
target := bytes.TrimSpace(line[m[2]:m[3]])
|
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
|
||||||
if !isValidWikiTarget(target) {
|
// not a link; leave the source text alone.
|
||||||
|
query := wikiSearchQuery(string(line[m[2]:m[3]]))
|
||||||
|
if query == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var display []byte
|
var display []byte
|
||||||
@@ -85,76 +87,12 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
|||||||
}
|
}
|
||||||
block.Advance(m[1])
|
block.Advance(m[1])
|
||||||
return &wikiLinkNode{
|
return &wikiLinkNode{
|
||||||
Target: append([]byte(nil), target...),
|
Query: []byte(query),
|
||||||
Display: append([]byte(nil), display...),
|
Display: append([]byte(nil), display...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and
|
type wikiLinkRenderer struct{}
|
||||||
// returns the cleaned absolute path.
|
|
||||||
func normalizeWikiTarget(target string) string {
|
|
||||||
if target == "/" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return "/" + strings.Trim(target, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// wikiTargetHref converts a wiki target to a URL href with each segment
|
|
||||||
// percent-encoded and a trailing slash appended.
|
|
||||||
func wikiTargetHref(target string) string {
|
|
||||||
target = normalizeWikiTarget(target)
|
|
||||||
if target == "/" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
|
|
||||||
b.WriteByte('/')
|
|
||||||
b.WriteString(url.PathEscape(seg))
|
|
||||||
}
|
|
||||||
b.WriteByte('/')
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// wikiFileHref converts a wiki target to a URL href for the raw file: each
|
|
||||||
// segment is percent-encoded and, unlike wikiTargetHref, no trailing slash is
|
|
||||||
// appended. Used for image-embed click-through so the href points straight at
|
|
||||||
// the file (companion click-interception skips anything ending in "/").
|
|
||||||
func wikiFileHref(target string) string {
|
|
||||||
target = normalizeWikiTarget(target)
|
|
||||||
if target == "/" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
|
|
||||||
b.WriteByte('/')
|
|
||||||
b.WriteString(url.PathEscape(seg))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// wikiTargetExists reports whether the on-disk path backing the target exists
|
|
||||||
// under root. Any existing path — file or folder — counts as resolved; only a
|
|
||||||
// missing path is treated as broken.
|
|
||||||
func wikiTargetExists(root, target string) bool {
|
|
||||||
target = normalizeWikiTarget(target)
|
|
||||||
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
|
||||||
_, err := os.Stat(fsPath)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
|
||||||
func wikiDefaultDisplay(target string) string {
|
|
||||||
target = normalizeWikiTarget(target)
|
|
||||||
if target == "/" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
segs := strings.Split(strings.TrimPrefix(target, "/"), "/")
|
|
||||||
return segs[len(segs)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
type wikiLinkRenderer struct {
|
|
||||||
root string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
reg.Register(kindWikiLink, r.render)
|
reg.Register(kindWikiLink, r.render)
|
||||||
@@ -165,38 +103,32 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
|
|||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
n := node.(*wikiLinkNode)
|
n := node.(*wikiLinkNode)
|
||||||
writeWikiLink(w, r.root, string(n.Target), string(n.Display))
|
writeWikiSearchLink(w, string(n.Query), string(n.Display))
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeWikiLink renders a wiki-link anchor for target with an optional display
|
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
|
||||||
// override. An empty display falls back to the target's last segment; a target
|
// search page, labelled with the display override or the query itself. Nothing
|
||||||
// that does not resolve on disk gets the `broken` class. Shared by the wiki-link
|
// is resolved at render time — whether the query matches anything is the search
|
||||||
// renderer and the embed renderer's link fallback.
|
// page's business, so there is no broken state here.
|
||||||
func writeWikiLink(w util.BufWriter, root, target, display string) {
|
func writeWikiSearchLink(w util.BufWriter, query, display string) {
|
||||||
href := wikiTargetHref(target)
|
|
||||||
if display == "" {
|
if display == "" {
|
||||||
display = wikiDefaultDisplay(target)
|
display = query
|
||||||
}
|
}
|
||||||
broken := !wikiTargetExists(root, target)
|
|
||||||
|
|
||||||
w.WriteString(`<a href="`)
|
w.WriteString(`<a href="`)
|
||||||
w.WriteString(href)
|
w.WriteString(wikiSearchHref(query))
|
||||||
w.WriteString(`"`)
|
w.WriteString(`">`)
|
||||||
if broken {
|
|
||||||
w.WriteString(` class="broken"`)
|
|
||||||
}
|
|
||||||
w.WriteString(`>`)
|
|
||||||
w.Write(util.EscapeHTML([]byte(display)))
|
w.Write(util.EscapeHTML([]byte(display)))
|
||||||
w.WriteString(`</a>`)
|
w.WriteString(`</a>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
type wikiLinkExt struct{ root string }
|
type wikiLinkExt struct{}
|
||||||
|
|
||||||
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
|
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
|
||||||
// links resolved against root.
|
// links to the search page. It needs no wiki root — a query is never resolved
|
||||||
func newWikiLinkExt(root string) goldmark.Extender {
|
// against the filesystem.
|
||||||
return &wikiLinkExt{root: root}
|
func newWikiLinkExt() goldmark.Extender {
|
||||||
|
return &wikiLinkExt{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
||||||
@@ -206,6 +138,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
|||||||
util.Prioritized(&wikiLinkParser{}, 199),
|
util.Prioritized(&wikiLinkParser{}, 199),
|
||||||
))
|
))
|
||||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||||
util.Prioritized(&wikiLinkRenderer{root: e.root}, 500),
|
util.Prioritized(&wikiLinkRenderer{}, 500),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user