Compare commits

...
15 Commits
Author SHA1 Message Date
luxick b48d476b39 JSON canvas initial 2026-09-22 21:28:28 +02:00
luxick 7b451bae86 Hide companion features on mobile 2026-09-22 21:24:29 +02:00
luxick 1925987459 Remove agent files 2026-09-22 21:24:15 +02:00
luxick b57e5d454b Rework wikilinks feature 2026-09-17 17:46:36 +02:00
luxick b14941b3e0 Adjust sidebar tree 2026-09-07 16:23:38 +02:00
luxick adf1540cf9 Update link colors 2026-09-07 15:37:43 +02:00
luxick c0b7ebf0c2 Increase dropdown button on mobile 2026-09-01 20:05:23 +02:00
luxick 07bb3b002e Remove remaining mentions of fabs 2026-09-01 19:55:40 +02:00
luxick 96a58ca472 reorganize header menu 2026-09-01 19:44:08 +02:00
luxick 5ad715426d Update section behavior 2026-08-23 14:25:12 +02:00
luxick c612cdd28d Remove unnecessary scrollbar CSS 2026-08-23 13:41:10 +02:00
luxick a0b4f49915 Merge TOC into page content 2026-08-23 13:38:45 +02:00
luxick dddacf9262 Update Headline rendering 2026-08-23 11:36:10 +02:00
luxick f78ebe7d5d Remove auto new tab for external links 2026-08-05 18:26:09 +02:00
luxick d943fc5596 Update CLAUDE.md 2026-08-05 18:25:52 +02:00
47 changed files with 3889 additions and 1191 deletions
+3
View File
@@ -11,3 +11,6 @@ companion/datascape-companion-*
# Editor build tooling deps (the built bundle is committed; node_modules is not)
editor-build/node_modules/
AGENTS.md
CLAUDE.md
-147
View File
@@ -1,147 +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.
## Project Overview
`datascape` is a minimal personal wiki where **the folder structure is the wiki**.
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
in a folder is that page's content.
## Build & Deploy
```bash
# Local build (host architecture)
go build .
# Deploy to NAS
make deploy
```
### Editor bundle (the one build-pipeline exception)
The page editor uses CodeMirror 6, vendored as a single pre-built IIFE at
`assets/editor/vendor/codemirror.bundle.js` and embedded via `embed.FS`. This is
the **only** deliberate exception to the "no build pipeline" rule below — it is a
one-time, committed artifact, not a runtime build. `go build` / `make deploy`
never touch Node and only consume the committed bundle.
Regenerate the bundle **only** when upgrading the `@codemirror/*` versions:
```bash
# bump versions in editor-build/package.json first, then:
make editor # runs `npm ci && npm run build` in editor-build/, rewrites the vendored bundle
```
Commit the regenerated `codemirror.bundle.js` and the updated
`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored.
The bundle is served immutable under a stable filename, so the edit template
appends `?v=<content-hash>` to its `<script>` src (`editorBundleVersion` in
`main.go`). The hash changes whenever the bundle bytes change, so a rebuilt
bundle busts client caches automatically — no manual version bump needed.
## HTTP API Surface
| Method | Path | Behaviour |
|--------|------|-----------|
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
| GET | `/{path}/?edit` | CodeMirror 6 editor initialized with `index.md` content |
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
content would be lost).
## Code Structure
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
Prefer separate, human-readable `.html` files over inlined HTML strings in Go. Embed them via `embed.FS` if needed.
## Architecture Rules
- **Single binary** — no installer, no runtime dependencies, no Docker
- **Go stdlib `net/http`** only — no web framework
- **`goldmark`** for Markdown rendering — no other Markdown libraries
- **`embed.FS`** for all assets — no external serving, no CDN
- **No database** of any kind
- **No indexing or caching** unless explicitly requested and justified
- Keep dependencies to an absolute minimum; if stdlib can do it, use stdlib
## Frontend Rules
- Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy)
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
- Do not inline JS in templates or merge unrelated features into one file
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
- Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut
- The editor is a *mode* of a page. Opening it pushes a normal history entry so
the browser/Android Back button cancels the edit and returns to the page — this
"back to cancel" gesture takes priority. `history-nav.js` only rewrites the way
*out*: leaving the editor via the same-page CANCEL link uses `location.replace`
so the editor entry is collapsed rather than sandwiched between two page entries
(which would let Back walk back into the editor). SAVE does the equivalent — it
POSTs via fetch and then `replaceState`s the editor entry with the saved page,
so the editor never lingers in history once you leave it (the pre-save page
snapshot one step back is the accepted cost). Links to a *different* page's
editor (new page / new child) push normally. The save POST answers `204` +
`X-Target` when the request carries `X-Save-Mode: replace`, because the target
may hold a `#section` anchor only the server can compute and fetch drops
fragments from followed redirects.
- For mutating modals (anything that POSTs and then navigates), call `closeModal()` and then `postReplace(action, body, target)` from `page/actions.js`. Do NOT use `<form>.submit()`. Two reasons:
1. The modal must be removed from the DOM before navigation, or the browser's bfcache snapshots it open and back-nav restores the modal.
2. `postReplace` uses `window.location.replace` so the action + result occupy a single history entry. A naive POST → 303 → GET creates two entries, and back-nav lands on a stale pre-mutation snapshot of the same page.
## CSS
Follow **SMACSS** conventions (Scalable and Modular Architecture for CSS). The stylesheet is organized into five categories:
- **Base** — element resets and global defaults only. Never style `header`, `textarea`, `input`, `aside`, `footer`, etc. directly for visual treatment — always via a class.
- **Layout** — `.row`, `.col`, `.page-wrap`. Use these for flex layout; do not inline `display: flex` on feature classes.
- **Modules** — reusable components: `.panel`, `.panel-header`, `.menu-row`, `.btn`, `.input`, `.muted`, `.truncate`, etc. New visual patterns should reuse these. Before adding a new module, check whether an existing one + a modifier already covers the case.
- **State** — `.is-*` prefix only (`.is-open`, `.is-selected`, `.is-active`, `.is-disabled`, `.is-empty`). State is the only place a class describes a moment in time rather than a structural role.
- **Theme** — colors, borders, spacing, and font sizes come from CSS variables defined in `:root` (`--bg`, `--secondary`, `--border`, `--border-dashed`, `--space-*`, `--font-*`). No hardcoded `1px solid #...`, no hardcoded rem spacing in component rules.
Naming: flat-dash (`.panel-header`, `.btn-small`), not BEM (`.panel__header--small`). Modifiers attach as additional classes (`<div class="btn btn-small">`), not as new standalone classes.
Anti-patterns to reject:
- One-off classes that duplicate an existing module (`.save-button` when `.btn` exists, `.form-name-input` when `.input` exists).
- Element selectors (`textarea { ... }`, `header { ... }`) for visual treatment — add a class instead.
- Inlining `display: flex; gap: X` on a feature class instead of composing with `.row` / `.col`.
- Adding a new module for a single use site — prefer a modifier on an existing module first.
- Hardcoded colors, border widths, or spacing values inside component rules — pull a variable, or add one to `:root` if it's missing.
## Development Priorities
When building features, apply this order:
1. Correctness on the filesystem — never corrupt or lose files
2. Mobile usability (primary editing device is Android over Wireguard VPN)
3. Simplicity of implementation, adhere to KISS
4. Performance
## Date Formatting
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
- Diary headings (year/month/day) are also ISO short form: `# 2026`, `## 2026-05`, `### 2026-05-28`. No long-form rendering.
- Calendar widget month names are German; the `germanMonths` map in `diary.go` keeps the labels keyed by `time.Month` since Go's `time.Format` is English-only.
## What to Avoid
- Any parallel folder structure (e.g. a separate `media/` tree mirroring `pages/`)
- Over-engineering auth — Basic auth is sufficient for a personal VPN tool
- Heavy payloads or expensive rendering (target CPU: ARMv7 32-bit NAS)
- Suggesting Docker (plain binary is preferred)
## Out of Scope (do not implement unless explicitly asked)
- Full-text search
- Browser-based file upload
- Version history / git integration
- Multi-user support
- Tagging or metadata beyond `index.md` content
+74 -1
View File
@@ -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.
- **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 — `![caption](photo.jpg "left")` — relative to the page they sit on.
- **Canvases** an infinite pan/zoom board stored as a [JSON Canvas](https://jsoncanvas.org/) `.canvas` file next to the page's `index.md`. Canvases in a folder appear as tabs above the page content, so a canvas is an alternative *view* of the page rather than a separate document. See the [Canvases](#canvases) section.
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
@@ -106,6 +108,77 @@ They resolve against the year page rather than separate per-day URLs:
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
## Canvases
A canvas is a `<Name>.canvas` file in a page folder holding a
[JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) document. Every canvas in a
folder renders as a tab above that page's content, and the page itself is the
first tab — so a page and its diagrams are one thing with several views.
| URL | What it does |
|------|-------------|
| `<page>/?canvas=<Name>` | the canvas view (canonical) |
| `<page>/<Name>.canvas` | redirects to the canonical form |
| `<page>/<Name>.canvas?raw` | the file itself |
Create one from the **NEW CANVAS** action or the `+` in the tab strip.
### Nodes
All four spec node types render:
| Type | Rendered as |
|------|-------------|
| `text` | markdown, rendered server-side — wikilinks, `![[embeds]]`, tables and task lists all behave as they do on a page |
| `file` | a transclusion of another wiki path: markdown pages (narrowed to a heading when `subpath` is set), images, video, PDFs |
| `link` | a card for an external URL |
| `group` | a labelled frame; dragging it carries the nodes inside it |
Node content is rendered by the server and cached in the browser by *content*,
so the whole canvas arrives pre-rendered in the initial page load (no per-node
requests) and undo/redo of a text edit costs nothing.
### Controls
| Gesture | Action |
|---------|--------|
| drag background | pan |
| ctrl/⌘ + wheel, or pinch | zoom |
| shift + drag background | marquee select |
| drag a node | move it (snaps to a 20px grid; hold alt to bypass) |
| drag a corner handle | resize |
| drag a side port | draw an edge to another node |
| double-click a node | edit it |
| double-click empty space | new text node there |
Keys: `Ctrl+S` save · `Ctrl+Z` / `Ctrl+Shift+Z` undo/redo · `Del` delete
selection · `Ctrl+A` select all · arrows nudge (shift for single pixels) ·
`T`/`F`/`L`/`G` add a node · `+`/`-`/`0` zoom, `9` fit · `Esc` cancel.
Undo covers the whole session; a drag or resize is one entry no matter how many
events it spanned.
### Saving
Saving is explicit (**SAVE**, or `Ctrl+S`) and uploads the whole document. The
server parses and validates it before writing anything, so a malformed payload
can never land on disk, and four things are checked in order:
1. the JSON parses into the spec's shape;
2. node ids are unique, every edge endpoint resolves, and enums/colours/sizes are in range;
3. the `X-Canvas-Version` token matches the file on disk — a mismatch is a `409` and you are asked whether to reload or overwrite;
4. a save that would empty a populated canvas is refused until you confirm it.
The previous contents are kept beside the file as `.<Name>.canvas.bak`, and the
write itself is atomic. Leaving the page with unsaved changes prompts first.
`[[wikilinks]]` inside text nodes are search queries, like on a page, so they
keep resolving after a move. File nodes and group backgrounds hold literal
paths and are not rewritten: moving or renaming their target leaves them
rendered as broken references.
Canvas *contents* are not searchable; only the file name is indexed.
## Quick-Add Bookmarklet
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
+182
View File
@@ -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
};
})();
+237
View File
@@ -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;
}
};
})();
+204
View File
@@ -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; }
};
})();
+461
View File
@@ -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; }
};
})();
+88
View File
@@ -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}}
+389
View File
@@ -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.
+125
View File
@@ -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); }
};
})();
+77
View File
@@ -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); }
};
})();
+147
View File
@@ -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
};
})();
+12
View File
@@ -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() {
// 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 () {
updateFooterIcon();
wireFileLinks();
+1 -1
View File
@@ -1,7 +1,7 @@
{{range .Sections}}
{{if eq .Level 1}}<h1 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h1>
{{else if eq .Level 2}}<h2 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h2>
{{else}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h3>{{end}}
{{else}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small btn-float">edit</a>{{end}}</h3>{{end}}
{{if .Body}}{{.Body}}{{end}}
{{if .Photos}}
<div class="photo-grid">
+3 -1
View File
@@ -9,6 +9,7 @@
</script>
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
{{if .Span}}<input type="hidden" name="span" value="1">{{end}}
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
<div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
@@ -29,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="codeblock" data-key="K" title="Code block (K)">```</button>
<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">
<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="embed" data-key="G" title="Embed file (G)">Embed file</button>
</div>
</span>
<span class="dropdown">
+102 -36
View File
@@ -223,6 +223,7 @@
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
embed: insertEmbed,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
@@ -240,43 +241,34 @@
movie: function () { M.run(movieCtx); },
};
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
// path, no empty/dot segments. Used to gate the modal's INSERT button.
function isValidWikiTarget(p) {
if (!p || p[0] !== '/') return false;
var trimmed = p.replace(/^\/+|\/+$/g, '');
if (trimmed === '') return true;
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.)
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
// plus an optional display-text field, inserting [[query]] or
// [[query::display]] at the cursor. Picking a suggestion only fills in that
// page's name — the link resolves through search at click time, so a path
// never enters the document. Inline `[[` typing uses the same suggestions
// via wikicomplete.js.
function insertWikilink() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to);
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var container = document.createElement('div');
var targetWrap = document.createElement('div');
var targetInput = document.createElement('input');
targetInput.type = 'text';
targetInput.className = 'input';
targetInput.placeholder = 'Page path or search…';
targetWrap.appendChild(targetInput);
var queryWrap = document.createElement('div');
var queryInput = document.createElement('input');
queryInput.type = 'text';
queryInput.className = 'input';
queryInput.placeholder = 'Search query';
// A selection is the query, not the label: [[selection]] renders with
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)';
if (selectedText) displayInput.value = selectedText;
container.appendChild(targetWrap);
container.appendChild(queryWrap);
container.appendChild(displayInput);
var handle = openModal({
@@ -284,30 +276,104 @@
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: true,
initiallyDisabled: !selectedText,
onConfirm: function () {
var target = targetInput.value.trim();
if (!isValidWikiTarget(target)) return;
var query = queryInput.value.trim();
if (!query) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
}
}
});
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,
container: targetWrap,
container: queryWrap,
onPick: function (r) {
targetInput.value = '/' + r.path;
queryInput.value = r.name;
updateConfirm();
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
));
}
}
});
}
});
}
+43 -85
View File
@@ -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
// fires only once the `[[` token's content begins with `/` (targets are
// absolute; free-text search lives in the toolbar modal instead). The content
// is split into a parent path (up to and including the last `/`) and a partial
// segment (the text after it); the parent's children are fetched from the
// existing `?tree=1` endpoint and filtered to names containing the partial
// (case-insensitive substring).
// Wiki links are search queries, so the popup is the same typeahead the header
// search box uses: the text typed after `[[` goes to `/_search` and each match
// is offered by name. Picking one replaces the query segment with that page's
// name — never a path — so what lands in the document is still a query. The
// trailing `]]` and any `::display` tail are left untouched.
//
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
// deeper; picking a file inserts `<name>` and stops. Only the current segment
// 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().
// Exposes window.WikiComplete.source for main.js to register via CM's
// autocompletion().
window.WikiComplete = (function () {
// treeURL builds the `?tree=1` request URL for an absolute parent path,
// percent-encoding each segment. A leading/trailing slash is tolerated;
// 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';
}
// Matches search-suggest.js so both surfaces fire at the same point.
var MIN_QUERY_LEN = 2;
function fetchTree(parent) {
return fetch(treeURL(parent), {
function fetchSuggestions(query) {
return fetch('/_search?q=' + encodeURIComponent(query), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).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);
return r.json();
});
}
// makeApply builds the apply() for a chosen entry. It replaces only the
// current segment ([from, to]); the trailing `]]` is untouched, so the
// cursor ends up parked before it. Folders append `/` and re-open the popup
// to drill into the next level; files terminate.
function makeApply(name, kind) {
return function (view, completion, from, to) {
var isFolder = kind === 'folder';
var insert = isFolder ? name + '/' : name;
view.dispatch({
changes: { from: from, to: to, insert: insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
});
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);
}
};
// queryEnd returns where the current query segment ends: the first `]`, the
// `::` that starts the display text, or end of line. Replacing only up to
// there keeps a re-edit from eating an existing alias or the closing `]]`.
function queryEnd(context, pos) {
var line = context.state.doc.lineAt(pos);
var to = pos;
while (to < line.to) {
var ch = context.state.sliceDoc(to, to + 1);
if (ch === ']') break;
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
to++;
}
return to;
}
// CM completion source. Activates when `[[` is followed by content that
// begins with `/`. The content is split at its last `/` into a parent path
// and a partial segment; the parent's children are fetched, filtered to
// names containing the partial (case-insensitive substring), and offered
// with name-only labels.
// CM completion source. Activates on `[[` followed by at least
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
// gets nothing.
function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null;
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 parent = content.slice(0, lastSlash + 1);
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++;
}
var from = match.from + 2;
var to = queryEnd(context, context.pos);
return new Promise(function (resolve) {
if (context.aborted) { resolve(null); return; }
fetchTree(parent).then(function (resp) {
if (context.aborted || !resp) { resolve(null); return; }
var needle = partial.toLowerCase();
var options = (resp.entries || []).reduce(function (acc, e) {
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
return acc;
}
acc.push({
label: e.name,
type: e.kind === 'folder' ? 'folder' : 'file',
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 });
fetchSuggestions(query).then(function (resp) {
if (context.aborted) { resolve(null); return; }
var options = (resp.results || []).map(function (r) {
return { label: r.name, detail: '/' + r.path, apply: r.name };
});
// filter:false — the server already ranked these fuzzily, and
// CM's own prefix filter would drop the near-misses (typos,
// token reorderings) that make the fuzzy search worth having.
// No validFor either: every keystroke re-queries from scratch.
resolve({ from: from, to: to, options: options, filter: false });
}).catch(function () {
resolve(null);
});
+11
View File
@@ -56,3 +56,14 @@ document.addEventListener('keydown', function (e) {
menu.classList.remove('is-open');
});
});
document.addEventListener('DOMContentLoaded', function () {
var trigger = document.querySelector('[data-action="actions-menu"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
});
});
+4
View File
@@ -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

+15 -5
View File
@@ -15,7 +15,7 @@
<script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
{{if .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
</head>
@@ -24,15 +24,25 @@
<nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
</nav>
{{if not .EditMode}}
{{if .ShowPageChrome}}
<form class="search-form" action="/" method="get">
<input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
</form>
{{end}}
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
<div class="header-actions row">
{{if not .ShowPageChrome}}{{block "headerActions" .}}{{end}}{{else}}
<span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu">
<div class="actions-special"></div>
{{template "headerActions" . }}
</div>
</span>
{{end}}
</div>
</header>
<div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar col">
{{if .ShowPageChrome}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div>
</aside>{{end}}
<div class="center">
@@ -48,7 +58,7 @@
</span>
</footer>
</div>
{{if not .EditMode}}<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>{{end}}
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
</div>
{{block "extras" .}}{{end}}
</body>
+3 -10
View File
@@ -1,8 +1,6 @@
// Full-viewport Overlay component (mobile). Distinct from the .modal dialog:
// this hosts a whole rail (the folder tree or the right-rail widgets/TOC) at
// full screen with its own scroll and a top-right close control. It reuses the
// low-level focus/Escape/bfcache patterns from modal.js rather than that
// component's panel chrome.
// this hosts a whole sidebar at full screen with its own scroll and a top-right close control.
// It reuses the low-level focus/Escape/bfcache patterns from modal.js rather than that component's panel chrome.
//
// openOverlay(node) MOVES `node` (an existing rail <aside>) into the overlay
// body and restores it to its original DOM position on close, so the rail keeps
@@ -28,7 +26,7 @@
closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn btn-fab overlay-close';
closeBtn.className = 'btn overlay-close';
closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '×';
@@ -37,11 +35,6 @@
bodyEl = document.createElement('div');
bodyEl.className = 'overlay-body';
// Any navigation or anchor activation inside a rail dismisses the
// overlay: tree/calendar links navigate away, TOC links scroll the
// page underneath — either way the user should see the result, and
// closing synchronously (before navigation) avoids a bfcache snapshot
// that would restore the overlay open on back-nav.
bodyEl.addEventListener('click', function (e) {
if (e.target.closest('a')) close();
});
+1 -16
View File
@@ -129,23 +129,9 @@ function movePage() {
input.placeholder = 'Page name';
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({
title: 'Move — new name?',
body: body,
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
@@ -154,7 +140,6 @@ function movePage() {
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
if (linksCheckbox.checked) action += '&links=1';
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, target);
-17
View File
@@ -1,17 +0,0 @@
(function () {
var content = document.querySelector('.content');
if (!content) return;
var headings = content.querySelectorAll('h2, h3, h4');
if (!headings.length) return;
headings.forEach(function (h) {
if (!h.id) return;
var a = document.createElement('a');
a.href = '#' + h.id;
a.className = 'heading-anchor';
a.setAttribute('aria-label', 'Link to this section');
a.textContent = '#';
h.insertBefore(a, h.firstChild);
});
}());
+43
View File
@@ -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);
}
}
});
}
+15
View File
@@ -0,0 +1,15 @@
document.addEventListener("DOMContentLoaded", function () {
var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return;
var item = document.createElement("button");
item.type = "button";
item.className = "btn btn-block";
item.title = "Contents";
item.textContent = "CONTENTS";
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside);
});
specials.appendChild(item);
});
-16
View File
@@ -1,16 +0,0 @@
// Wires the header-right [ACTIONS] dropdown. The buttons inside keep calling
// the existing page/actions.js handlers (newPage / movePage / deletePage) and
// the ?edit link, so the N/E/M keyboard shortcuts continue to work whether or
// not the menu is open. Toggling reuses the generic wireDropdown mechanism
// (global-shortcuts.js), which also handles outside-click / Escape close.
(function () {
var trigger = document.querySelector('[data-action="page-actions"]');
if (!trigger || typeof wireDropdown !== 'function') return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
// Keep aria-expanded in sync with the menu's open state.
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? 'true' : 'false');
});
})();
+22 -19
View File
@@ -1,24 +1,28 @@
{{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}}
<span class="dropdown">
<button type="button" class="btn" data-action="page-actions" aria-haspopup="true" aria-expanded="false" title="Page actions">ACTIONS</button>
<div class="dropdown-menu align-right">
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
{{end}}
{{if not .IsRoot}}
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
{{end}}
</div>
</span>
<script src="/_/page/header-actions.js"></script>
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
{{end}}
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<button class="btn btn-block" onclick="newCanvas()" title="New canvas">NEW CANVAS</button>
{{end}}{{end}}
{{define "content"}}
{{/* Canvases in this folder are alternative views of the same page, so they
read as a tab strip above the content rather than as rows in the file
listing (listEntries filters them out for exactly this reason). */}}
{{if .Canvases}}
<nav class="canvas-tabs row" aria-label="Page views">
<span class="canvas-tab is-active">PAGE</span>
{{range .Canvases}}<a class="canvas-tab" href="{{.URL}}">{{.Name}}</a>{{end}}
{{if .CanEdit}}<button type="button" class="canvas-tab canvas-tab-add" onclick="newCanvas()" title="New canvas">+</button>{{end}}
</nav>
{{end}}
{{if .Content}}
<div class="content">{{.Content}}</div>
{{end}}
@@ -57,14 +61,13 @@
{{end}}
{{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script>
<script src="/_/page/anchors.js"></script>
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
<script src="/_/page/tasks.js"></script>
{{end}}
{{if .Content}}
<script src="/_/page/sections.js"></script>
{{end}}
<script src="/_/page/sidebar-fab.js"></script>
<script src="/_/page/contents.js"></script>
{{end}}
{{define "sidebar"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
+10 -5
View File
@@ -4,14 +4,19 @@
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (!headings.length) return;
// Section 0 is pre-heading content, editable via full-page edit.
// Sections 1..N each start at a heading; that is the index sent to the server.
// Skip headings that already carry a server-rendered edit link.
headings.forEach(function (h, i) {
if (h.querySelector('a.btn')) return;
var a = document.createElement('a');
a.href = '?edit&section=' + (i + 1);
a.className = 'btn btn-small';
if (h.tagName === 'H1') {
a.href = '?edit';
} else if (h.tagName === 'H2') {
a.href = '?edit&section=' + (i + 1) + '&span';
} else {
return;
}
a.className = 'btn btn-small btn-float';
a.textContent = 'edit';
h.appendChild(document.createTextNode(' '))
h.appendChild(a);
-20
View File
@@ -1,20 +0,0 @@
// Mobile: the right-rail FAB is stacked ABOVE the tree FAB and opens the right
// rail (TOC + page widgets) in the full-viewport Overlay (overlay.js). It only
// appears when the rail actually has content — runs after toc.js has had a
// chance to populate the rail (script order in page/main.html), so an
// otherwise-empty rail shows no FAB. Hidden on desktop by .fab CSS.
document.addEventListener("DOMContentLoaded", function () {
var aside = document.querySelector("aside.sidebar");
if (!aside || !aside.children.length) return;
var fab = document.createElement("button");
fab.type = "button";
fab.className = "btn btn-fab fab fab-rail";
fab.title = "Contents";
fab.setAttribute("aria-label", "Contents");
fab.textContent = "≡";
fab.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside);
});
document.body.appendChild(fab);
});
+3 -5
View File
@@ -6,7 +6,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (headings.length < 2) return;
var nav = document.createElement("nav");
nav.className = "toc panel panel-sidebar";
nav.className = "toc panel";
var header = document.createElement("div");
header.className = "panel-header";
@@ -21,14 +21,12 @@ document.addEventListener("DOMContentLoaded", function () {
var a = document.createElement("a");
a.href = "#" + h.id;
var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted, .heading-anchor, .dropdown").forEach(function (el) { el.remove(); });
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
});
nav.appendChild(list);
// Stack the TOC on top of any server-rendered widget(s) already in the rail.
var rail = document.querySelector("aside.sidebar");
rail.insertBefore(nav, rail.firstChild);
content.insertBefore(nav, content.firstChild);
});
-1
View File
@@ -53,7 +53,6 @@ function createSearchPage(name) {
}
document.addEventListener('DOMContentLoaded', function () {
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
+4 -9
View File
@@ -42,15 +42,10 @@
{{end}}
{{end}}
{{define "headerActions"}}
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
{{end}}
{{define "footerExtras"}}
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
{{end}}
{{define "extras"}}
<div class="fab dropdown">
<button class="btn btn-fab" data-action="actions-drop" title="Actions" aria-label="Actions">≡</button>
<div class="dropdown-menu align-right open-up">
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
</div>
</div>
{{end}}
+379 -107
View File
@@ -24,13 +24,21 @@
--link-hover: #d6d24d;
--danger: #c40141;
--danger-hover: #d03467;
/* Translucent scrim for badges/overlays sitting on top of media. */
--overlay: rgba(0, 0, 0, 0.55);
/* JSON Canvas preset colours "1".."6" (red, orange, yellow, green, cyan,
purple), pulled toward the app palette so a canvas does not look like a
different program. Hex colours in a .canvas file pass through untouched. */
--canvas-1: #e05555;
--canvas-2: #e08a3c;
--canvas-3: #d9b03c;
--canvas-4: #5aab64;
--canvas-5: #01b6c4;
--canvas-6: #a069c4;
--canvas-edge: #9a9a9a;
--border: 1px solid var(--secondary);
--border-dashed: 1px dashed var(--secondary);
/* Subtle vertical rail for tree-nesting depth — quieter than --border so
it reads as a guide, not a divider. */
--border-guide: 1px solid var(--bg-panel-hover);
--space-1: 0.25rem;
@@ -75,11 +83,9 @@ body {
* { box-sizing: border-box; margin: 0; padding: 0; }
a { color: var(--text); text-decoration: none; }
a:hover { color: var(--primary-hover); }
a:hover { color: var(--link-hover); }
.content a { color: var(--link); }
.content a:hover { color: var(--link-hover); }
.content a.broken { color: var(--primary-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; }
@@ -209,6 +215,7 @@ footer {
.btn::after { content: "]"; color: var(--secondary); }
.btn:hover { color: var(--primary-hover); }
.btn-small { font-size: 0.8rem; font-weight: normal; vertical-align: middle; }
.btn-float {float: right;}
.btn-tool { padding: 0 0.15rem; }
.btn-block {
display: flex;
@@ -219,22 +226,9 @@ footer {
padding: 0.3rem var(--space-3);
white-space: nowrap;
}
.btn-fab {
background: var(--bg-panel);
border: var(--border);
width: 3rem;
height: 3rem;
font-size: 1.5rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-fab::before, .btn-fab::after { content: none; }
.btn-fab:hover { background: var(--bg-panel-hover); color: var(--primary-hover); }
.btn[hidden] {display: none;}
.danger { color: var(--danger); }
.danger:hover { color: var(--danger-hover); }
/* Selected segmented-toggle button (view-settings modal). */
.btn.is-active { color: var(--primary-hover); }
/* === Form controls ===
@@ -268,7 +262,7 @@ footer {
.content h4, .content h5, .content h6,
main > h2 {
color: var(--text);
margin: 1.25rem 0 var(--space-2);
margin: var(--space-5) 0 var(--space-2);
line-height: 1.3;
}
.content h1 {
@@ -276,7 +270,12 @@ main > h2 {
border-bottom: var(--border-dashed);
padding-bottom: var(--space-1);
}
.content h2, main > h2 { font-size: 1.4rem; }
.content h1:first-child { margin-top: 0; }
.content h2, main > h2 {
font-size: 1.4rem;
border-bottom: var(--border-dashed);
padding-bottom: var(--space-1);
}
.content h3 { font-size: 1.15rem; }
.content p { margin: var(--space-3) 0; }
.content ul, .content ol { margin: var(--space-3) 0 var(--space-3) var(--space-5); }
@@ -344,14 +343,6 @@ main > h2 {
line-height: 1.4;
}
a.heading-anchor {
color: var(--text-muted);
margin-right: 0.4em;
font-weight: normal;
text-decoration: none;
}
a.heading-anchor:hover { color: var(--primary-hover); }
/* === Data tables ===
Shared style for the file listing, search-suggestion dropdown, and
markdown content tables. .data-table-grid adds per-cell borders + a
@@ -408,6 +399,8 @@ a.heading-anchor:hover { color: var(--primary-hover); }
.dropdown-menu.is-open { display: block; }
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;}
/* === Suggestion dropdown (header search + editor link picker) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
a dashed border and bg-panel-hover for the active row. */
@@ -506,15 +499,6 @@ body.edit-mode footer { max-width: none; }
border-bottom: var(--border-dashed);
}
/* === Floating action button ===
Standalone FAB buttons (the tree rail, the right rail) are mobile-only and
stack bottom-right: the tree FAB anchors the bottom, the right-rail FAB sits
above it. Wrapped FABs (the search actions dropdown) stay visible on desktop
and, on mobile, sit in the upper slot so they never overlap the tree FAB. */
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
button.fab { display: none; }
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
/* === Companion status === */
.companion-status { margin-left: auto; }
.companion-icon { font-size: 0.9rem; line-height: 1; padding: 0.1rem 0.3rem; }
@@ -593,21 +577,9 @@ button.fab { display: none; }
color: var(--secondary);
}
.thumb-label { font-size: var(--font-sm); }
.empty { padding: var(--space-4); text-align: center; }
/* === Scrollbars === */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #111; }
::-webkit-scrollbar-thumb { background: var(--primary); }
::-webkit-scrollbar-thumb:hover { background: var(--primary-hover); }
/* === Sidebar (right rail) ===
Full-height column in the app-shell that scrolls its own overflow (TOC on
top, page widget(s) below). Collapses out of the flex row when it has no
content so the center column widens. A dashed separator to main mirrors the
left tree rail; widgets within drop their panel outlines (see
.panel-sidebar). */
/* === Optional Sidebar (right side) === */
.sidebar {
width: 14rem;
flex-shrink: 0;
@@ -619,21 +591,23 @@ button.fab { display: none; }
border-left: var(--border-dashed);
}
aside.sidebar:empty { display: none; }
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
Drops the .panel outline so the rail reads as a clean column separated from
main by the .sidebar dashed border, matching the left tree rail. Inside the
mobile Overlay the widgets render as plain full-width content. */
.panel-sidebar {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
border: none;
}
/* === Table of contents ===
Rendered as a .panel-sidebar block in the right rail (padding + font-size
come from that density class); toc.js always mounts it there, on both
desktop and inside the mobile Overlay. */
/* === Table of contents ===*/
.toc {
float: right;
width: 15rem;
max-width: 45%;
margin: 0 0 var(--space-3) var(--space-4);
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
max-width: 60vh;
overflow-y: auto;
}
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc li { margin: 0.15rem 0; }
.toc a {
@@ -706,7 +680,13 @@ aside.sidebar:empty { display: none; }
border-bottom: var(--border-dashed);
flex-shrink: 0;
}
.overlay-close { width: 2.5rem; height: 2.5rem; font-size: 1.5rem; }
.overlay-close {
width: 2.5rem;
height: 2.5rem;
font-size: 1.5rem;
}
.overlay-close::before, .overlay-close::after { content: none; }
.overlay-close:hover { background: var(--bg-panel-hover); color: var(--primary-hover); }
.overlay-body {
flex: 1;
overflow-y: auto;
@@ -743,14 +723,13 @@ aside.sidebar:empty { display: none; }
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row.is-active > .tree-name { color: var(--secondary); }
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; }
.tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; }
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Each nesting level gets a vertical guide rail down its left edge so depth
stays scannable in the compact view (cf. VS Code / file-explorer trees). */
.tree-children {
margin-left: var(--space-2);
padding-left: var(--space-2);
@@ -761,24 +740,9 @@ aside.sidebar:empty { display: none; }
padding: var(--space-1) 0;
word-break: break-all;
}
/* Current-page row in the navigation rail. Mirrors .tree-row.is-selected so the
two tree surfaces stay visually consistent. */
.tree-row.is-active {
background: var(--bg-panel-hover);
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row.is-active > .tree-name { color: var(--primary-hover); }
/* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
Desktop: a full-height flex column in the app-shell (composes with .col)
holding the tree's own scroll region. Mobile: not laid out inline — its
content is surfaced through the Overlay via the stacked FABs (see
responsive). */
/* No right padding on the aside: the scrolling children below reach the
border-right so their scrollbars sit flush against the divider line (they
pad their own content off the scrollbar instead). */
/* Tree sidebar (persistent navigation) */
.tree-sidebar {
width: var(--tree-width);
flex-shrink: 0;
@@ -786,16 +750,12 @@ aside.sidebar:empty { display: none; }
padding: var(--space-4) 0 var(--space-4) var(--space-4);
border-right: var(--border-dashed);
}
/* The tree's scroll region: the tree overflows here. Also the node the tree FAB
moves into the Overlay, so font sizing lives here rather than on the aside. */
.tree-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: var(--space-2);
font-size: var(--font-sm);
/* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave. */
user-select: none;
}
@@ -873,20 +833,25 @@ aside.sidebar:empty { display: none; }
/* === Responsive === */
@media (max-width: 1100px) {
/* Rails are not laid out inline on mobile — their content is surfaced
through the Overlay via the stacked FABs. The <aside> elements stay in
the DOM (their JS renders into them and the Overlay moves them in/out);
we only pull them out of the shell flow here. */
.tree-sidebar, .sidebar { display: none; }
/* Compact three-column header: logo left, search stretches the middle,
actions right. */
header { grid-template-columns: auto 1fr auto; }
.search-form { width: 100%; max-width: none; justify-self: stretch; }
/* Reveal the mobile FAB stack (tree at the bottom, right rail above it).
The search actions dropdown FAB moves to the upper slot too so it never
overlaps the tree FAB. */
button.fab { display: inline-flex; }
.fab.dropdown { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
.actions-menu .btn-block {
min-height: 2.75rem;
padding: 0.8rem var(--space-4);
border-top: var(--border-dashed);
}
.actions-menu > .btn-block:first-child,
.actions-special > .btn-block:first-child { border-top: none; }
.actions-menu .btn-block:hover,
.actions-menu .btn-block:active { background: var(--bg-panel-hover); }
}
/* Show the actions menu items directly in the bar in desktop size */
@media (min-width: 1101px) {
.actions-toggle, .actions-special {display: none;}
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
.actions-menu .btn-block { width: auto; padding: 0;}
}
@media (max-width: 600px) {
@@ -894,10 +859,6 @@ aside.sidebar:empty { display: none; }
main { padding: var(--space-4) var(--space-3); }
.app-name {display: none;}
.editor-cm { min-height: 50vh; }
/* Editing on mobile is full-bleed: drop the main inset so the toolbar and
editor use the entire viewport width. */
body.edit-mode main { padding: 0; }
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
.editor-toolbar .btn-tool {
display: inline-flex;
@@ -907,17 +868,10 @@ aside.sidebar:empty { display: none; }
min-height: 2rem;
padding: 0 var(--space-1);
}
/* Pin the toolbar above the on-screen keyboard rather than at the top, which
is out of thumb reach while typing. interactive-widget=resizes-content
(layout.html viewport) shrinks the viewport on keyboard open so bottom: 0
sits directly above it. cm-content reserves matching scroll space so the
last lines aren't hidden behind the bar. */
body.edit-mode .editor-toolbar {
position: fixed;
left: 0;
right: 0;
/* Reset the desktop sticky `top: 0`; with it, fixed + top + bottom would
stretch the bar to the full viewport height. */
top: auto;
bottom: 0;
z-index: 50;
@@ -928,6 +882,7 @@ aside.sidebar:empty { display: none; }
body.edit-mode .cm-content {
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
}
body.edit-mode main { padding: 0; }
.modal-backdrop { padding: var(--space-2); align-items: flex-start; }
.modal { max-width: none; margin-top: var(--space-4); }
.modal .panel-header { cursor: default; }
@@ -943,4 +898,321 @@ aside.sidebar:empty { display: none; }
.list-item td.icon { grid-row: 1; grid-column: 1; }
.list-item td.name { grid-row: 1; grid-column: 2; }
.list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; }
.toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);}
}
/* === Canvas ===
A canvas is an alternative view of a page (see canvas.go). The tab strip is
shared with the normal page template; everything below it only exists in
canvas mode. */
/* --- view switcher (rendered on normal pages too) --- */
.canvas-tabs {
gap: 0;
flex-wrap: wrap;
border-bottom: var(--border-dashed);
margin-bottom: var(--space-4);
}
.canvas-tab {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
color: var(--text-muted);
background: none;
border: 1px solid transparent;
border-bottom: none;
/* Overlap the strip's own border so the active tab joins it. */
margin-bottom: -1px;
font: inherit;
font-size: var(--font-sm);
cursor: pointer;
}
.canvas-tab:hover { color: var(--link-hover); }
.canvas-tab.is-active {
color: var(--text);
border-color: var(--secondary);
background: var(--bg-panel);
}
.canvas-tab-add { color: var(--secondary); }
/* --- full-bleed view mode ---
The canvas owns the viewport instead of the centred reading column: main
loses its max-width and padding, and the centre column stops scrolling so the
surface can handle pan itself. */
body.canvas-mode .center { overflow: hidden; }
body.canvas-mode main {
max-width: none;
padding: 0;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
body.canvas-mode footer { max-width: none; }
body.canvas-mode .canvas-tabs { margin: 0; padding: var(--space-2) var(--space-4) 0; }
.canvas-app {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.canvas-toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
padding: 0.4rem 0.6rem;
background: var(--bg-panel-hover);
border-top: var(--border);
border-bottom: var(--border);
flex: 0 0 auto;
}
.canvas-status { margin-left: auto; padding-left: var(--space-3); }
.canvas-swatch {
display: inline-block;
width: 0.7em;
height: 0.7em;
vertical-align: middle;
margin-right: 0.3em;
border: 1px solid var(--bg);
}
.canvas-swatch[data-swatch="1"] { background: var(--canvas-1); }
.canvas-swatch[data-swatch="2"] { background: var(--canvas-2); }
.canvas-swatch[data-swatch="3"] { background: var(--canvas-3); }
.canvas-swatch[data-swatch="4"] { background: var(--canvas-4); }
.canvas-swatch[data-swatch="5"] { background: var(--canvas-5); }
.canvas-swatch[data-swatch="6"] { background: var(--canvas-6); }
/* --- surface, world, layers ---
touch-action:none hands every touch gesture to the pointer handlers; without
it the browser claims one-finger drags for scrolling and two-finger for page
zoom, and neither reaches the canvas. */
.canvas-surface {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
touch-action: none;
outline: none;
cursor: grab;
background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1px);
background-repeat: repeat;
}
.canvas-surface.is-grid-hidden { background-image: none; }
/* The world is a zero-size anchor: all of its content is absolutely positioned
in world coordinates and the whole thing is moved by one transform, so pan
and zoom never touch a single node's layout. */
.canvas-world {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
transform-origin: 0 0;
will-change: transform;
}
.canvas-layer { position: absolute; top: 0; left: 0; }
/* Drawn in world coordinates with no viewport of its own; overflow:visible is
what lets it paint outside its 1x1 box, including at negative coordinates. */
.canvas-edges {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
overflow: visible;
}
/* --- nodes --- */
.canvas-node {
position: absolute;
display: flex;
flex-direction: column;
border: 1px solid var(--node-accent, var(--secondary));
background: var(--bg-panel);
/* Visible so the connection ports and resize handles, which sit on the
border, are not clipped; the body clips its own content instead. */
overflow: visible;
contain: layout paint;
}
.canvas-node[data-color="1"] { --node-accent: var(--canvas-1); }
.canvas-node[data-color="2"] { --node-accent: var(--canvas-2); }
.canvas-node[data-color="3"] { --node-accent: var(--canvas-3); }
.canvas-node[data-color="4"] { --node-accent: var(--canvas-4); }
.canvas-node[data-color="5"] { --node-accent: var(--canvas-5); }
.canvas-node[data-color="6"] { --node-accent: var(--canvas-6); }
.canvas-node.is-selected { outline: 2px solid var(--primary-hover); outline-offset: 1px; }
.canvas-node.is-edge-target { outline: 2px dashed var(--link); outline-offset: 1px; }
.canvas-node-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--space-3);
font-size: var(--font-sm);
}
.canvas-node-body.content { margin-bottom: 0; }
.canvas-node-body > :first-child { margin-top: 0; }
.canvas-node-body > :last-child { margin-bottom: 0; }
.canvas-node-body h1 { font-size: 1.1rem; }
.canvas-node-body h2 { font-size: 1rem; }
.canvas-node-body h3 { font-size: 0.95rem; }
/* Task checkboxes are rendered enabled for pages, where page/tasks.js writes
the toggle back to index.md. Inside a canvas node there is no such wiring and
the markdown lives in the .canvas file, so a click would be a lie. */
.canvas-node-body .task-checkbox { pointer-events: none; }
.canvas-node-title {
flex: 0 0 auto;
padding: var(--space-1) var(--space-3);
font-size: var(--font-xs);
color: var(--text-muted);
border-bottom: var(--border-dashed);
}
/* A transcluded image or player fills its node edge to edge. */
.canvas-node-body:has(> .canvas-media) { padding: 0; overflow: hidden; }
.canvas-media { display: block; width: 100%; height: 100%; }
.canvas-media img { display: block; width: 100%; height: 100%; object-fit: contain; }
.canvas-broken { color: var(--danger-hover); }
.canvas-file-fallback { padding: var(--space-2) 0; }
.canvas-link-card {
display: flex;
flex-direction: column;
gap: var(--space-1);
align-items: flex-start;
max-width: 100%;
}
.canvas-link-host { color: var(--link); }
.canvas-link-url { font-size: var(--font-xs); color: var(--text-muted); max-width: 100%; }
.link-favicon { vertical-align: middle; margin-right: 3px; }
.canvas-node-group {
background: rgba(255, 255, 255, 0.03);
border: 1px dashed var(--node-accent, var(--bg-panel-hover));
background-position: center;
}
.canvas-node-group[data-bg-style="cover"] { background-size: cover; }
.canvas-node-group[data-bg-style="ratio"] { background-size: contain; background-repeat: no-repeat; }
.canvas-node-group[data-bg-style="repeat"] { background-repeat: repeat; }
.canvas-node-group .canvas-node-body { display: none; }
.canvas-group-label {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
color: var(--text-muted);
}
/* --- ports and handles ---
Both are positioned on the node border (the negative margin centres them on
it) and stay invisible until the node is hovered or selected, so a dense
canvas is not covered in dots. */
.canvas-port {
position: absolute;
width: 12px;
height: 12px;
margin: -6px;
border-radius: 50%;
background: var(--bg);
border: 1px solid var(--secondary);
opacity: 0;
cursor: crosshair;
}
.canvas-node:hover .canvas-port,
.canvas-node.is-selected .canvas-port { opacity: 1; }
.canvas-port:hover { background: var(--primary-hover); }
.canvas-port-top { top: 0; left: 50%; }
.canvas-port-bottom { top: 100%; left: 50%; }
.canvas-port-left { top: 50%; left: 0; }
.canvas-port-right { top: 50%; left: 100%; }
.canvas-handle {
position: absolute;
width: 10px;
height: 10px;
margin: -5px;
background: var(--primary-hover);
opacity: 0;
}
.canvas-node.is-selected .canvas-handle { opacity: 1; }
.canvas-handle-nw { top: 0; left: 0; cursor: nwse-resize; }
.canvas-handle-ne { top: 0; left: 100%; cursor: nesw-resize; }
.canvas-handle-se { top: 100%; left: 100%; cursor: nwse-resize; }
.canvas-handle-sw { top: 100%; left: 0; cursor: nesw-resize; }
/* --- edges ---
The hit path is a fat transparent stroke under the visible curve: a 2px
bezier is effectively unclickable without it. */
.canvas-edge-hit {
stroke: transparent;
stroke-width: 16;
pointer-events: stroke;
cursor: pointer;
}
.canvas-edge-line { stroke-width: 2; pointer-events: none; }
.canvas-edge-head { pointer-events: none; }
.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); stroke-width: 3; }
.canvas-edge.is-selected .canvas-edge-head { fill: var(--primary-hover); }
.canvas-edge-label {
font-family: "Iosevka Etoile", monospace;
font-size: 12px;
fill: var(--text);
/* A stroke painted under the fill gives the label a halo against whatever
it overlaps, without measuring a background rect. */
stroke: var(--bg);
stroke-width: 4;
paint-order: stroke;
pointer-events: none;
}
.canvas-edge-temp {
stroke: var(--primary-hover);
stroke-width: 2;
stroke-dasharray: 6 4;
pointer-events: none;
}
/* --- overlays --- */
.canvas-marquee {
position: absolute;
border: 1px dashed var(--primary-hover);
background: rgba(214, 77, 149, 0.12);
pointer-events: none;
}
.canvas-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 30rem;
padding: var(--space-4);
text-align: center;
pointer-events: none;
}
/* The node editor is fixed to the viewport rather than mounted in the zoomed
world: CodeMirror measures itself in device pixels and a scaled ancestor
breaks its caret and selection maths. */
.canvas-editor {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
}
.canvas-editor .panel-header { padding: var(--space-2) var(--space-3); margin-bottom: 0; }
.canvas-editor-mount { flex: 1 1 auto; min-height: 0; overflow: auto; }
.canvas-editor-mount .cm-editor { height: 100%; }
/* The shared editor theme sets a 60vh floor for the full-page editor; in a
node-sized panel that would force a scrollbar at every size. The theme is
injected at runtime and lands after this sheet, so it needs overriding. */
.canvas-editor-mount .cm-scroller { min-height: 0 !important; }
@media (max-width: 600px) {
.canvas-toolbar { gap: var(--space-2); padding: var(--space-2); }
.canvas-toolbar .btn-tool { padding: 0.2rem 0.4rem; }
body.canvas-mode .canvas-tabs { padding: var(--space-2) var(--space-2) 0; }
/* Touch has no hover, so ports would never appear; show them on selection
only and make both targets finger-sized. */
.canvas-port { width: 16px; height: 16px; margin: -8px; }
.canvas-handle { width: 14px; height: 14px; margin: -7px; }
}
+197 -193
View File
@@ -1,211 +1,215 @@
// Persistent left navigation rail. Renders the wiki folder/file tree, expanded
// to the current page's ancestor chain, and lets the user navigate by clicking
// folders (links) or open files locally via the companion (a.tree-file, wired
// in companion.js). Shares the .tree-* CSS and the ?tree endpoint with
// tree-picker.js but does not reuse its row builder — that one is modal-select
// behavior, this one is navigation behavior. Hidden in edit mode (the server
// omits the container).
;(function () {
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
if (!container) return
(function () {
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector('aside.tree-sidebar .tree-scroll');
if (!container) return;
function joinPath(parent, name) {
if (parent === "/" || parent === "") return "/" + name
return parent.replace(/\/+$/, "") + "/" + name
}
function joinPath(parent, name) {
if (parent === '/' || parent === '') return '/' + name;
return parent.replace(/\/+$/, '') + '/' + name;
function encodeSegments(p) {
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
}
function folderHref(p) {
if (p === "/" || p === "") return "/"
return "/" + encodeSegments(p) + "/"
}
function fetchFolder(path, expandTo) {
var url = folderHref(path) + "?tree=1&foldersOnly=1"
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
return fetch(url, { credentials: "same-origin" }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status)
return r.json()
})
}
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
// thus fullPath) are decoded, so we must decode here too or paths containing
// spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, "")
if (p === "") return "/"
try {
return decodeURIComponent(p)
} catch (e) {
return p
}
})()
var activeRow = null
function disabledRow(text) {
var row = document.createElement("div")
row.className = "tree-row is-disabled"
row.textContent = text
return row
}
// renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) {
entries.forEach(function (entry) {
var built = buildRow(parentPath, entry)
containerEl.appendChild(built.row)
if (built.preExpand) built.open()
})
}
function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name)
var row = document.createElement("div")
row.className = "tree-row"
var chevron = document.createElement("span")
chevron.className = "tree-chevron"
chevron.textContent = "▸"
row.appendChild(chevron)
var link = document.createElement("a")
link.className = "tree-name tree-folder"
link.textContent = entry.name
link.href = folderHref(fullPath)
row.appendChild(link)
if (fullPath === activePath) {
row.classList.add("is-active")
activeRow = row
}
function encodeSegments(p) {
return p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
var preChildren = entry.children ? entry.children : null
var childrenEl = null
var loaded = false
var isOpen = false
var isLeaf = false
function ensureChildrenEl() {
if (!childrenEl) {
childrenEl = document.createElement("div")
childrenEl.className = "tree-children"
}
return childrenEl
}
function folderHref(p) {
if (p === '/' || p === '') return '/';
return '/' + encodeSegments(p) + '/';
function markLeaf() {
isLeaf = true
isOpen = false
chevron.textContent = "▸"
chevron.classList.add("is-leaf")
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
}
function fileHref(p) {
return '/' + encodeSegments(p);
function loadChildren() {
var el = ensureChildrenEl()
el.textContent = ""
el.appendChild(disabledRow("…"))
fetchFolder(fullPath)
.then(function (resp) {
el.textContent = ""
loaded = true
if (!resp.entries || resp.entries.length === 0) {
markLeaf()
return
}
renderInto(el, fullPath, resp.entries)
})
.catch(function () {
el.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function (e) {
e.stopPropagation()
loadChildren()
})
el.appendChild(err)
})
}
function fetchFolder(path, expandTo) {
var url = folderHref(path) + '?tree=1';
if (expandTo) url += '&expandTo=' + encodeURIComponent(expandTo);
return fetch(url, { credentials: 'same-origin' }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
// thus fullPath) are decoded, so we must decode here too or paths containing
// spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, '');
if (p === '') return '/';
try { return decodeURIComponent(p); } catch (e) { return p; }
})();
var activeRow = null;
function disabledRow(text) {
var row = document.createElement('div');
row.className = 'tree-row is-disabled';
row.textContent = text;
return row;
}
// renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) {
if (!entries || entries.length === 0) {
containerEl.appendChild(disabledRow('(empty)'));
return;
function open() {
if (isOpen || isLeaf) return
var el = ensureChildrenEl()
row.parentNode.insertBefore(el, row.nextSibling)
chevron.textContent = "▾" // ▾
isOpen = true
if (!loaded) {
if (preChildren) {
renderInto(el, fullPath, preChildren)
loaded = true
} else {
loadChildren()
}
entries.forEach(function (entry) {
var built = buildRow(parentPath, entry);
containerEl.appendChild(built.row);
if (built.preExpand) built.open();
});
}
}
function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name);
var isFolder = entry.kind === 'folder';
var row = document.createElement('div');
row.className = 'tree-row';
var chevron = document.createElement('span');
chevron.className = 'tree-chevron';
if (isFolder) chevron.textContent = '▸'; // ▸
else chevron.classList.add('is-leaf');
row.appendChild(chevron);
var link = document.createElement('a');
link.className = 'tree-name ' + (isFolder ? 'tree-folder' : 'tree-file');
link.textContent = entry.name;
link.href = isFolder ? folderHref(fullPath) : fileHref(fullPath);
row.appendChild(link);
if (isFolder && fullPath === activePath) {
row.classList.add('is-active');
activeRow = row;
}
var preChildren = (isFolder && entry.children) ? entry.children : null;
var childrenEl = null;
var loaded = false;
var isOpen = false;
function ensureChildrenEl() {
if (!childrenEl) {
childrenEl = document.createElement('div');
childrenEl.className = 'tree-children';
}
return childrenEl;
}
function loadChildren() {
var el = ensureChildrenEl();
el.textContent = '';
el.appendChild(disabledRow('…'));
fetchFolder(fullPath).then(function (resp) {
el.textContent = '';
renderInto(el, fullPath, resp.entries);
loaded = true;
}).catch(function () {
el.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function (e) {
e.stopPropagation();
loadChildren();
});
el.appendChild(err);
});
}
function open() {
if (!isFolder || isOpen) return;
var el = ensureChildrenEl();
row.parentNode.insertBefore(el, row.nextSibling);
chevron.textContent = '▾'; // ▾
isOpen = true;
if (!loaded) {
if (preChildren) {
renderInto(el, fullPath, preChildren);
loaded = true;
} else {
loadChildren();
}
}
}
function close() {
if (!isFolder || !isOpen) return;
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl);
}
chevron.textContent = '▸';
isOpen = false;
}
// Clicking the name link navigates (folder) or opens the file; clicking
// anywhere else on the row toggles expansion for folders.
row.addEventListener('click', function (e) {
if (e.target.closest('a.tree-name')) return;
if (!isFolder) return;
if (isOpen) close(); else open();
});
return { row: row, open: open, preExpand: !!preChildren };
function close() {
if (!isOpen) return
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
chevron.textContent = "▸"
isOpen = false
}
function render() {
container.textContent = '';
var listing = document.createElement('div');
container.appendChild(listing);
listing.appendChild(disabledRow('…'));
// Clicking the name link navigates (folder) or opens the file; clicking
// anywhere else on the row toggles expansion for folders.
row.addEventListener("click", function (e) {
if (e.target.closest("a.tree-name")) return
if (isOpen) close()
else open()
})
fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) {
listing.textContent = '';
renderInto(listing, '/', resp.entries);
if (activeRow) {
try { activeRow.scrollIntoView({ block: 'nearest' }); } catch (e) {}
}
}).catch(function () {
listing.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function () { render(); });
listing.appendChild(err);
});
}
return { row: row, open: open, preExpand: !!preChildren }
}
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and
// opens the tree's scroll container in the full-viewport Overlay
// (overlay.js). The container always exists when not editing, so — per the
// layout spec — the FAB always renders; the overlay auto-closes when a
// folder/file link inside it navigates. Hidden on desktop by .fab CSS.
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';
fab.className = 'btn btn-fab fab fab-tree';
fab.title = 'Folder tree';
fab.setAttribute('aria-label', 'Folder tree');
fab.innerHTML = '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>';
fab.addEventListener('click', function () {
if (typeof openOverlay === 'function') openOverlay(container);
});
document.body.appendChild(fab);
}
function render() {
container.textContent = ""
var listing = document.createElement("div")
container.appendChild(listing)
listing.appendChild(disabledRow("…"))
render();
setupFab();
})();
fetchFolder("/", activePath === "/" ? "" : activePath)
.then(function (resp) {
listing.textContent = ""
renderInto(listing, "/", resp.entries)
if (activeRow) {
try {
activeRow.scrollIntoView({ block: "nearest" })
} catch (e) {}
}
})
.catch(function () {
listing.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function () {
render()
})
listing.appendChild(err)
})
}
function setupMenuItem() {
var specials = document.querySelector(".actions-special")
if (!specials) return
var item = document.createElement("button")
item.type = "button"
item.className = "btn btn-block"
item.title = "Folders"
item.textContent = "FOLDERS"
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(container)
})
specials.appendChild(item)
}
render()
setupMenuItem()
})()
+699
View File
@@ -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))
}
-17
View File
@@ -10,7 +10,6 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -208,22 +207,6 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia
return 0, "", "", false
}
// headingTextRe matches an ATX heading at the start of a section. The
// heading text is everything after the `#`s and the required space, on the
// first line.
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
// sectionHeading returns the heading level (1..6) and trimmed text of a
// section produced by splitSections. Returns level=0 for the pre-heading
// section (index 0).
func sectionHeading(section []byte) (level int, text string) {
m := headingTextRe.FindSubmatch(section)
if m == nil {
return 0, ""
}
return len(m[1]), strings.TrimSpace(string(m[2]))
}
// findSectionIndex returns the absolute section index whose heading text
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
func findSectionIndex(sections [][]byte, target string) (int, bool) {
+91 -159
View File
@@ -3,7 +3,7 @@ package main
import (
"bytes"
"path"
"regexp"
"strconv"
"strings"
"github.com/yuin/goldmark"
@@ -14,105 +14,95 @@ import (
"github.com/yuin/goldmark/util"
)
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
// The inner blob forbids brackets and newlines but allows ':' so the whole
// target::caption::align payload travels as one capture — the caption and
// alignment tail is split apart in Go (parseEmbedFields). This mirrors
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
// Embeds are ordinary Markdown images: ![caption](photo.jpg "left").
//
// Destinations are relative to the page they sit on. Pages always render at a
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
// resolves the destination and nothing here builds a URL or touches the
// filesystem. Goldmark's own image parser produces the nodes; only rendering
// is overridden. The title slot carries the float alignment.
const (
alignLeft = "left"
alignRight = "right"
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.
type wikiEmbedNode struct {
ast.BaseInline
Target string
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) {
// embedAlign maps an image title to its float class. A title that is not an
// alignment keyword is ignored and the embed floats right.
func embedAlign(title []byte) string {
switch strings.TrimSpace(string(title)) {
case alignLeft:
return alignLeft, true
case alignRight:
return alignRight, true
return alignLeft
case alignCenter:
return alignCenter, true
return alignCenter
}
return "", false
return alignRight
}
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
// alignment per the embed syntax rules. The target is the field before the
// first "::". The trailing "::"-fields are interpreted as:
// - none: no caption, default alignment (right)
// - one keyword field: that alignment, no caption
// - 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
// caption may contain a literal "::" as long as an alignment field trails it.
func parseEmbedFields(inner string) (target, caption, align string) {
fields := strings.Split(inner, "::")
target = strings.TrimSpace(fields[0])
tail := fields[1:]
align = alignRight
switch len(tail) {
case 0:
// target only
case 1:
if kw, ok := alignKeyword(tail[0]); ok {
align = kw
// embedIsImage reports whether a destination names an image, i.e. whether it
// renders as a <figure> rather than degrading to a link. Extension-only — no
// stat, so a destination gone stale simply 404s when clicked.
func embedIsImage(dest []byte) bool {
return isImageFile(path.Base(string(dest)))
}
type embedRenderer struct{}
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindImage, r.render)
}
// render writes a <figure> holding a thumbnail that links to the full file,
// with the image's inline children as <figcaption>. Children are rendered by
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
// emphasis or a link. A non-image destination renders as a plain anchor
// labelled with the caption, or with the file name when there is none.
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
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 {
caption = strings.TrimSpace(tail[0])
w.WriteString(`</a>`)
}
default:
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
align = kw
return ast.WalkContinue, nil
}
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
}
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 "![[" opener; a bare '!' (or a Markdown image "![](…)")
// falls through to goldmark's default image parser at priority 200.
if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
return nil
if captioned {
w.WriteString(`</figcaption>`)
}
m := wikiEmbedRe.FindSubmatchIndex(line)
if m == 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}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
// 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
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>.
type embedBlockTransformer struct{ root string }
type embedBlockTransformer struct{}
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
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.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
if _, ok := c.(*wikiEmbedNode); ok {
if _, ok := c.(*ast.Image); ok {
parent.InsertBefore(parent, p, c)
}
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
// embed and nothing else visible — only such embeds and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
// that degrades to an inline link (missing / non-image target), stays wrapped so
// the fallback anchor keeps its paragraph.
// 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
// image that degrades to an inline link (non-image destination), stays wrapped
// so the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
case *wikiEmbedNode:
if !embedIsImage(t.root, n.Target) {
case *ast.Image:
if !embedIsImage(n.Destination) {
return false
}
hasFigure = true
@@ -178,77 +168,19 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
return hasFigure
}
// embedIsImage reports whether target resolves to an existing image file, i.e.
// 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 embedExt struct{}
type wikiEmbedRenderer struct {
root string
}
// newEmbedExt returns a goldmark extension that renders Markdown images as
// 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) {
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),
))
func (e *embedExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{root: e.root}, 100),
util.Prioritized(embedBlockTransformer{}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
util.Prioritized(&embedRenderer{}, 500),
))
}
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
type extLinksTransformer struct{}
func (extLinksTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
link, ok := n.(*ast.Link)
if !ok {
return ast.WalkContinue, nil
}
if isExternalURL(string(link.Destination)) {
link.SetAttribute([]byte("target"), []byte("_blank"))
link.SetAttribute([]byte("rel"), []byte("noopener noreferrer"))
}
return ast.WalkContinue, nil
})
}
func isExternalURL(dest string) bool {
if strings.HasPrefix(dest, "//") {
return true
}
i := strings.Index(dest, ":")
if i <= 0 {
return false
}
for _, c := range dest[:i] {
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
!(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' {
return false
}
}
return true
}
type extLinksExt struct{}
func newExtLinksExt() goldmark.Extender { return &extLinksExt{} }
func (e *extLinksExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(extLinksTransformer{}, 999),
))
}
+90 -11
View File
@@ -10,6 +10,7 @@ import (
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -47,6 +48,7 @@ var (
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/canvas/main.html"))
)
// specialPage is the result returned by a pageTypeHandler.
@@ -105,7 +107,7 @@ func main() {
log.Fatal(err)
}
initMarkdown(root)
initMarkdown()
authKey, err := loadOrCreateAuthKey(root)
if err != nil {
@@ -124,6 +126,7 @@ func main() {
http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest)
http.HandleFunc("/_canvas/render", h.handleCanvasRender)
http.HandleFunc("/quickadd", h.handleQuickAdd)
http.Handle("/", h)
@@ -212,6 +215,16 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// A canvas is addressed as a view of its folder (?canvas=Name), so a direct
// hit on the file collapses onto that canonical form — the same shape as
// diary.go's virtual-URL redirects. ?raw keeps the bytes reachable.
if r.Method == http.MethodGet && strings.HasSuffix(urlPath, canvasExt) && !r.URL.Query().Has("raw") {
if target, ok := canvasViewURL(urlPath); ok {
http.Redirect(w, r, target, http.StatusFound)
return
}
}
info, err := os.Stat(fsPath)
if err != nil {
if os.IsNotExist(err) {
@@ -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
// slash (path.Clean strips it) and the content would be lost.
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
}
h.serveDir(w, r, urlPath, fsPath)
@@ -232,14 +245,39 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if info.IsDir() {
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 += "/"
}
h.serveDir(w, r, urlPath, fsPath)
} 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)
}
}
// 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) {
_, editMode := r.URL.Query()["edit"]
@@ -248,6 +286,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
return
}
// An explicit ?canvas= selects this page's canvas view. Checked ahead of
// the pageTypeHandler redirect loop so an explicit view request always wins
// over a folder-type redirect (e.g. a canvas kept in a diary year folder).
//
// This is deliberately not a pageTypeHandler: that interface injects content
// into the standard page body, while a canvas replaces the whole body with a
// full-bleed surface and its own controls — a view mode like ?edit, which is
// likewise a template branch here rather than a handler.
if name := r.URL.Query().Get("canvas"); name != "" {
h.serveCanvas(w, r, urlPath, fsPath, name)
return
}
for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
http.Redirect(w, r, target, http.StatusFound)
@@ -261,6 +312,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
// Determine section index (-1 = whole page).
sectionIndex := -1
insertBefore := -1
sectionSpan := false
if editMode {
if s := r.URL.Query().Get("section"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
@@ -272,6 +324,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
insertBefore = n
}
}
sectionSpan = r.URL.Query().Has("span")
}
var special *specialPage
@@ -288,6 +341,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
rendered = renderMarkdown(rawMD)
}
// Canvases in this folder surface as a view switcher above the content, so
// a page and its diagrams read as one thing. Suppressed in the editor, which
// is about the markdown only.
var canvases []canvasRef
if !editMode {
canvases = listCanvases(fsPath, urlPath, "")
}
view, sortKey, order := readPageSettings(fsPath).viewSettings()
var entries []entry
if !editMode && (special == nil || !special.SuppressListing) {
@@ -323,7 +384,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
} else if editMode && sectionIndex >= 0 {
sections := splitSections(rawMD)
if sectionIndex < len(sections) {
rawContent = string(sections[sectionIndex])
end := sectionIndex + 1
if sectionSpan {
end = secionSpanEnd(sections, sectionIndex)
}
rawContent = string(joinSections(sections[sectionIndex:end]))
}
} else if editMode && rawContent == "" && urlPath != "/" {
rawContent = "# " + pageTitle(urlPath) + "\n\n"
@@ -336,6 +401,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
IsRoot: urlPath == "/",
SectionIndex: sectionIndex,
InsertBefore: insertBefore,
Span: sectionSpan,
PostURL: urlPath,
RawContent: rawContent,
Content: rendered,
@@ -346,6 +412,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
SpecialContent: specialContent,
SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC,
Canvases: canvases,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -361,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) {
query := r.URL.Query()
// Checked first so ?canvas=X&delete removes that canvas rather than falling
// through to the page-level delete below.
if name := query.Get("canvas"); name != "" {
h.handleCanvasPost(w, r, urlPath, fsPath, name)
return
}
if query.Has("delete") {
h.handleDelete(w, r, urlPath, fsPath)
return
}
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
}
if query.Has("toggle") {
@@ -390,11 +463,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
indexPath := filepath.Join(fsPath, "index.md")
redirectTarget := urlPath
// insert_before splices a new section into the file *at* index N rather
// than replacing index N (used by the diary "create new day" flow).
// section replaces the section at index N (used by per-section edits).
// Exactly one of insert_before / section should be set; insert_before
// wins if both are present.
// handle section saving
if s := r.FormValue("insert_before"); s != "" {
insertIndex, err := strconv.Atoi(s)
if err != nil || insertIndex < 0 {
@@ -431,8 +500,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
return
}
sections[sectionIndex] = []byte(content)
content = string(joinSections(sections))
// handling of section editing that spans until the next heading of the same level
end := sectionIndex + 1
if r.FormValue("span") != "" {
end = secionSpanEnd(sections, sectionIndex)
}
merged := make([][]byte, 0, len(sections)-(end-sectionIndex)+1)
merged = append(merged, sections[:sectionIndex]...)
merged = append(merged, []byte(content))
merged = append(merged, sections[end:]...)
content = string(joinSections(merged))
// Section index ≥ 1 is a heading-anchored section. Redirect to its
// anchor so the user lands on the section they just saved, even if
// the heading text changed.
+5 -118
View File
@@ -1,19 +1,14 @@
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When
// 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.
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
//
// 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
@@ -21,7 +16,7 @@ import (
// 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
// 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)
if oldPath == "/" {
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
}
// Phase 1: optionally walk the tree and rewrite every index.md that
// 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.
// Phase 1: create intermediate parent folders for the destination.
if parent := filepath.Dir(dstFsPath); parent != "" {
if err := os.MkdirAll(parent, 0755); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
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
// destination and drops the emptied source.
if merging {
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
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
@@ -248,77 +206,6 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
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
// and renames it into place so readers never observe a partial file.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+45 -5
View File
@@ -19,12 +19,12 @@ import (
var md goldmark.Markdown
// initMarkdown builds the package-level goldmark instance. Called once from
// main after the wiki root is known so the wiki-link extension can resolve
// targets against the filesystem.
func initMarkdown(root string) {
// initMarkdown builds the package-level goldmark instance. Neither extension
// resolves anything against the filesystem — wiki links are search queries and
// embed destinations are relative to the page — so this needs no wiki root.
func initMarkdown() {
md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root), newExtLinksExt()),
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
)
@@ -52,6 +52,7 @@ type pageData struct {
IsRoot bool
SectionIndex int // -1 = whole page; >=0 = section being edited
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
Span bool // causes section editing to also cover every sub-section to the next section of the same level
PostURL string
RawContent string
Content template.HTML
@@ -63,6 +64,39 @@ type pageData struct {
SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64
// Canvases lists every .canvas file in this folder as a view-switcher tab.
// ActiveCanvas names the one currently being viewed ("" on a normal page),
// and is what makes this page render as a canvas rather than as markdown.
Canvases []canvasRef
ActiveCanvas string
// CanvasJSON is the whole canvas document plus its server-rendered node
// HTML, inlined into the page so opening a canvas costs one request.
CanvasJSON template.JS
// CanvasError carries a parse/validation failure to the template, which
// shows it in place of the canvas instead of failing the request.
CanvasError string
}
// layoutData is the contract every view built on assets/layout.html satisfies.
// The layout is shared by pageData and searchPageData, and a method only one of
// them has fails at render time with a half-written response rather than at
// build time — so each is asserted against the interface here.
type layoutData interface {
ShowPageChrome() bool
}
var (
_ layoutData = pageData{}
_ layoutData = searchPageData{}
)
// ShowPageChrome reports whether the standard page furniture — search box,
// folder-tree rail, right sidebar, and the collapsed ACTIONS dropdown — should
// render. The editor and the canvas view both take the full width and supply
// their own controls, so both opt out.
func (d pageData) ShowPageChrome() bool {
return !d.EditMode && d.ActiveCanvas == ""
}
// Allowed values for the listing view settings. Unknown values in the file or
@@ -129,6 +163,7 @@ var (
iconAudio = readIcon("audio")
iconArchive = readIcon("archive")
iconGeneric = readIcon("generic")
iconCanvas = readIcon("canvas")
)
// renderMarkdown converts raw markdown to trusted HTML.
@@ -210,6 +245,9 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
if name == "index.md" {
continue // rendered above, don't list it
}
if isCanvasFile(name) {
continue // surfaced as a view-switcher tab above the content
}
f := entry{
Icon: fileIcon(name),
Name: name,
@@ -299,6 +337,8 @@ func readIcon(name string) template.HTML {
func fileIcon(name string) template.HTML {
ext := strings.ToLower(path.Ext(name))
switch ext {
case ".canvas":
return iconCanvas
case ".md", ".pdf":
return iconDoc
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
+5
View File
@@ -49,6 +49,11 @@ type searchPageData struct {
RenderMS int64
}
// ShowPageChrome satisfies the shared layout's contract (see layoutData). The
// results page is always ordinary chrome — it is never the editor and never a
// canvas — but EditMode is honoured so the two views cannot drift apart.
func (d searchPageData) ShowPageChrome() bool { return !d.EditMode }
// indexEntry is the shared scoreable core of both folder and file index
// entries: a forward-slash relative path plus its pre-tokenized basename so
// the per-query scoring loop avoids redoing the lowercasing and tokenization
+34
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"regexp"
"strings"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
@@ -10,6 +11,11 @@ import (
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
// headingTextRe matches an ATX heading at the start of a section. The
// heading text is everything after the `#`s and the required space, on the
// first line.
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
// splitSections splits raw markdown into sections.
// Section 0 is any content before the first heading.
// Each subsequent section begins at a heading line and runs to the next.
@@ -64,3 +70,31 @@ func joinSections(sections [][]byte) []byte {
}
return buf.Bytes()
}
// sectionHeading returns the heading level (1..6) and trimmed text of a
// section produced by splitSections. Returns level=0 for the pre-heading
// section (index 0).
func sectionHeading(section []byte) (level int, text string) {
m := headingTextRe.FindSubmatch(section)
if m == nil {
return 0, ""
}
return len(m[1]), strings.TrimSpace(string(m[2]))
}
// sectionSpanEnd returns the exclusive end index of the section span starting
// at start: the first following section whose heading level is <= start's.
// Sections nested deeper than start (its subsections) belong to the span, so
// editing a `##` covers every `###`+ under it up to the next `##` or `#`.
func secionSpanEnd(sections [][]byte, start int) int {
level, _ := sectionHeading(sections[start])
if level == 0 {
return start + 1
}
for i := start + 1; i < len(sections); i++ {
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
return i
}
}
return len(sections)
}
+7 -2
View File
@@ -96,9 +96,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad path", http.StatusBadRequest)
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)
if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden)
+10 -12
View File
@@ -40,18 +40,16 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
return
}
entries, err := listTreeEntries(fsPath)
foldersOnly := r.URL.Query().Has("foldersOnly")
entries, err := listTreeEntries(fsPath, foldersOnly)
if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError)
return
}
// expandTo asks for a nested listing: each folder along the ancestor chain
// carries its own children, recursively, down to the target. Used by the
// tree sidebar to render the current page's chain in one request. The flat
// picker omits expandTo and is unaffected.
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
expandTreeChain(fsPath, entries, treePathSegments(expandTo))
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
}
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
@@ -70,8 +68,8 @@ func canonicalTreePath(urlPath string) string {
// listTreeEntries returns the immediate children of fsPath, filtering hidden
// entries and index.md. Folders are listed before files; both groups are
// sorted alphabetically.
func listTreeEntries(fsPath string) ([]treeEntry, error) {
// sorted alphabetically. foldersOnly will not list any individual files at all.
func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
raw, err := os.ReadDir(fsPath)
if err != nil {
return nil, err
@@ -85,7 +83,7 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
if e.IsDir() {
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
} else {
if name == "index.md" {
if foldersOnly || name == "index.md" {
continue
}
files = append(files, treeEntry{Name: name, Kind: "file"})
@@ -113,7 +111,7 @@ func treePathSegments(p string) []string {
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string) {
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 {
return
}
@@ -122,12 +120,12 @@ func expandTreeChain(fsPath string, entries []treeEntry, segs []string) {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs)
kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:])
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return
}
}
+48 -116
View File
@@ -3,8 +3,6 @@ package main
import (
"bytes"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
@@ -16,20 +14,17 @@ import (
"github.com/yuin/goldmark/util"
)
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
// of the current inline reader. Target and display forbid newlines and
// brackets; the target is non-greedy so the first `::` separates target from
// display when both are present.
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
// the current inline reader. Query and display forbid newlines and brackets;
// the query is non-greedy so the first `::` separates query from display when
// both are present.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
// Used by the move-endpoint rewriter; not by the goldmark parser.
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkNode is the AST node produced by wikiLinkParser.
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
// normalized by wikiSearchQuery.
type wikiLinkNode struct {
ast.BaseInline
Target []byte
Query []byte
Display []byte
}
@@ -39,27 +34,32 @@ func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
func (n *wikiLinkNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": string(n.Target),
"Query": string(n.Query),
"Display": string(n.Display),
}, nil)
}
// isValidWikiTarget rejects targets that are not absolute or that contain
// traversal / empty segments. Matches the validation used by the move endpoint.
func isValidWikiTarget(target []byte) bool {
if len(target) == 0 || target[0] != '/' {
return false
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
// A leading `/` marks a link written under the old path-based syntax; those
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
// existing content keeps resolving without a rewrite. Everything else is free
// 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), "/")
if trimmed == "" {
return true // root link
trimmed := strings.Trim(q, "/")
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
return strings.TrimSpace(trimmed[i+1:])
}
for _, seg := range strings.Split(trimmed, "/") {
if seg == "" || seg == "." || seg == ".." {
return false
}
}
return true
return trimmed
}
// 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.
func wikiSearchHref(query string) string {
return "/?q=" + url.QueryEscape(query)
}
type wikiLinkParser struct{}
@@ -75,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
if m == nil {
return nil
}
target := bytes.TrimSpace(line[m[2]:m[3]])
if !isValidWikiTarget(target) {
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
// not a link; leave the source text alone.
query := wikiSearchQuery(string(line[m[2]:m[3]]))
if query == "" {
return nil
}
var display []byte
@@ -85,76 +87,12 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
}
block.Advance(m[1])
return &wikiLinkNode{
Target: append([]byte(nil), target...),
Query: []byte(query),
Display: append([]byte(nil), display...),
}
}
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and
// 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
}
type wikiLinkRenderer struct{}
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
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
}
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
}
// writeWikiLink renders a wiki-link anchor for target with an optional display
// override. An empty display falls back to the target's last segment; a target
// that does not resolve on disk gets the `broken` class. Shared by the wiki-link
// renderer and the embed renderer's link fallback.
func writeWikiLink(w util.BufWriter, root, target, display string) {
href := wikiTargetHref(target)
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
// search page, labelled with the display override or the query itself. Nothing
// is resolved at render time — whether the query matches anything is the search
// page's business, so there is no broken state here.
func writeWikiSearchLink(w util.BufWriter, query, display string) {
if display == "" {
display = wikiDefaultDisplay(target)
display = query
}
broken := !wikiTargetExists(root, target)
w.WriteString(`<a href="`)
w.WriteString(href)
w.WriteString(`"`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.WriteString(wikiSearchHref(query))
w.WriteString(`">`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
}
type wikiLinkExt struct{ root string }
type wikiLinkExt struct{}
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
// links resolved against root.
func newWikiLinkExt(root string) goldmark.Extender {
return &wikiLinkExt{root: root}
// links to the search page. It needs no wiki root — a query is never resolved
// against the filesystem.
func newWikiLinkExt() goldmark.Extender {
return &wikiLinkExt{}
}
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
@@ -206,6 +138,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
util.Prioritized(&wikiLinkParser{}, 199),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiLinkRenderer{root: e.root}, 500),
util.Prioritized(&wikiLinkRenderer{}, 500),
))
}