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 tooling deps (the built bundle is committed; node_modules is not)
editor-build/node_modules/ 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. - **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. - **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
@@ -106,6 +108,77 @@ They resolve against the year page rather than separate per-day URLs:
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page. Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
## Canvases
A canvas is a `<Name>.canvas` file in a page folder holding a
[JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) document. Every canvas in a
folder renders as a tab above that page's content, and the page itself is the
first tab — so a page and its diagrams are one thing with several views.
| URL | What it does |
|------|-------------|
| `<page>/?canvas=<Name>` | the canvas view (canonical) |
| `<page>/<Name>.canvas` | redirects to the canonical form |
| `<page>/<Name>.canvas?raw` | the file itself |
Create one from the **NEW CANVAS** action or the `+` in the tab strip.
### Nodes
All four spec node types render:
| Type | Rendered as |
|------|-------------|
| `text` | markdown, rendered server-side — wikilinks, `![[embeds]]`, tables and task lists all behave as they do on a page |
| `file` | a transclusion of another wiki path: markdown pages (narrowed to a heading when `subpath` is set), images, video, PDFs |
| `link` | a card for an external URL |
| `group` | a labelled frame; dragging it carries the nodes inside it |
Node content is rendered by the server and cached in the browser by *content*,
so the whole canvas arrives pre-rendered in the initial page load (no per-node
requests) and undo/redo of a text edit costs nothing.
### Controls
| Gesture | Action |
|---------|--------|
| drag background | pan |
| ctrl/⌘ + wheel, or pinch | zoom |
| shift + drag background | marquee select |
| drag a node | move it (snaps to a 20px grid; hold alt to bypass) |
| drag a corner handle | resize |
| drag a side port | draw an edge to another node |
| double-click a node | edit it |
| double-click empty space | new text node there |
Keys: `Ctrl+S` save · `Ctrl+Z` / `Ctrl+Shift+Z` undo/redo · `Del` delete
selection · `Ctrl+A` select all · arrows nudge (shift for single pixels) ·
`T`/`F`/`L`/`G` add a node · `+`/`-`/`0` zoom, `9` fit · `Esc` cancel.
Undo covers the whole session; a drag or resize is one entry no matter how many
events it spanned.
### Saving
Saving is explicit (**SAVE**, or `Ctrl+S`) and uploads the whole document. The
server parses and validates it before writing anything, so a malformed payload
can never land on disk, and four things are checked in order:
1. the JSON parses into the spec's shape;
2. node ids are unique, every edge endpoint resolves, and enums/colours/sizes are in range;
3. the `X-Canvas-Version` token matches the file on disk — a mismatch is a `409` and you are asked whether to reload or overwrite;
4. a save that would empty a populated canvas is refused until you confirm it.
The previous contents are kept beside the file as `.<Name>.canvas.bak`, and the
write itself is atomic. Leaving the page with unsaved changes prompts first.
`[[wikilinks]]` inside text nodes are search queries, like on a page, so they
keep resolving after a move. File nodes and group backgrounds hold literal
paths and are not rewritten: moving or renaming their target leaves them
rendered as broken references.
Canvas *contents* are not searchable; only the file name is indexed.
## Quick-Add Bookmarklet ## Quick-Add Bookmarklet
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target): Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
+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() { function init() {
// Every companion element is rendered hidden, so bailing out here
// leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return;
probeStatus().then(function () { probeStatus().then(function () {
updateFooterIcon(); updateFooterIcon();
wireFileLinks(); wireFileLinks();
+1 -1
View File
@@ -1,7 +1,7 @@
{{range .Sections}} {{range .Sections}}
{{if eq .Level 1}}<h1 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h1> {{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 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 .Body}}{{.Body}}{{end}}
{{if .Photos}} {{if .Photos}}
<div class="photo-grid"> <div class="photo-grid">
+3 -1
View File
@@ -9,6 +9,7 @@
</script> </script>
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}"> <form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}} {{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}} {{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
<div class="editor-toolbar"> <div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button> <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="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button> <button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="dropdown"> <span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button> <button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button> <button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button> <button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
</div> </div>
</span> </span>
<span class="dropdown"> <span class="dropdown">
+102 -36
View File
@@ -223,6 +223,7 @@
quote: function () { linePrefix('> '); }, quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); }, link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink, wikilink: insertWikilink,
embed: insertEmbed,
ul: function () { linePrefix('- '); }, ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); }, ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); }, task: function () { linePrefix('- [ ] '); },
@@ -240,43 +241,34 @@
movie: function () { M.run(movieCtx); }, movie: function () { M.run(movieCtx); },
}; };
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute // Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
// path, no empty/dot segments. Used to gate the modal's INSERT button. // plus an optional display-text field, inserting [[query]] or
function isValidWikiTarget(p) { // [[query::display]] at the cursor. Picking a suggestion only fills in that
if (!p || p[0] !== '/') return false; // page's name — the link resolves through search at click time, so a path
var trimmed = p.replace(/^\/+|\/+$/g, ''); // never enters the document. Inline `[[` typing uses the same suggestions
if (trimmed === '') return true; // via wikicomplete.js.
var segs = trimmed.split('/');
for (var i = 0; i < segs.length; i++) {
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
}
return true;
}
// Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
// full /_search typeahead plus an optional display-text field, then insert
// [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
// the folder-scoped completion in wikicomplete.js instead.)
function insertWikilink() { function insertWikilink() {
var sel = view.state.selection.main; var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to); var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var container = document.createElement('div'); var container = document.createElement('div');
var targetWrap = document.createElement('div'); var queryWrap = document.createElement('div');
var targetInput = document.createElement('input'); var queryInput = document.createElement('input');
targetInput.type = 'text'; queryInput.type = 'text';
targetInput.className = 'input'; queryInput.className = 'input';
targetInput.placeholder = 'Page path or search…'; queryInput.placeholder = 'Search query';
targetWrap.appendChild(targetInput); // A selection is the query, not the label: [[selection]] renders with
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input'); var displayInput = document.createElement('input');
displayInput.type = 'text'; displayInput.type = 'text';
displayInput.className = 'input'; displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)'; displayInput.placeholder = 'Display text (optional)';
if (selectedText) displayInput.value = selectedText;
container.appendChild(targetWrap); container.appendChild(queryWrap);
container.appendChild(displayInput); container.appendChild(displayInput);
var handle = openModal({ var handle = openModal({
@@ -284,30 +276,104 @@
body: container, body: container,
confirm: { confirm: {
label: 'INSERT', label: 'INSERT',
initiallyDisabled: true, initiallyDisabled: !selectedText,
onConfirm: function () { onConfirm: function () {
var target = targetInput.value.trim(); var query = queryInput.value.trim();
if (!isValidWikiTarget(target)) return; if (!query) return;
var display = displayInput.value.trim(); var display = displayInput.value.trim();
handle.close(); handle.close();
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]'); insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
} }
} }
}); });
function updateConfirm() { function updateConfirm() {
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim())); handle.setConfirmDisabled(!queryInput.value.trim());
} }
targetInput.addEventListener('input', updateConfirm); queryInput.addEventListener('input', updateConfirm);
window.attachSuggestions(targetInput, { window.attachSuggestions(queryInput, {
showFooter: false, showFooter: false,
container: targetWrap, container: queryWrap,
onPick: function (r) { onPick: function (r) {
targetInput.value = '/' + r.path; queryInput.value = r.name;
updateConfirm(); updateConfirm();
displayInput.focus(); displayInput.focus();
displayInput.select(); }
});
}
// embedDest turns the absolute wiki path the tree picker hands back into the
// destination written to the document: relative to the page being edited
// when the file lives under it, absolute otherwise. Pages render at a
// slash-terminated URL, so a relative destination resolves against the page
// in the browser.
function embedDest(absPath) {
var base = decodeURIComponent(window.location.pathname);
if (base.slice(-1) !== '/') base += '/';
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
}
// embedToken formats a Markdown image. A destination carrying spaces or
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
// piece — and the title slot holds the alignment keyword the renderer reads.
// Right is the renderer's default, so it is left off.
function embedToken(dest, caption, align) {
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
}
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
// caption and an alignment. Images render as a floated figure; anything
// else degrades to a link.
function insertEmbed() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
openTreePicker({
title: 'Embed — pick a file',
mode: 'file',
initialPath: here,
confirmLabel: 'NEXT',
onSelect: function (absPath) {
var container = document.createElement('div');
var captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'input';
captionInput.placeholder = 'Caption (optional)';
captionInput.value = selectedText;
var alignSelect = document.createElement('select');
alignSelect.className = 'input';
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
.forEach(function (opt) {
var o = document.createElement('option');
o.value = opt[0];
o.textContent = opt[1];
alignSelect.appendChild(o);
});
container.appendChild(captionInput);
container.appendChild(alignSelect);
var handle = openModal({
title: 'Embed — caption?',
body: container,
confirm: {
label: 'INSERT',
onConfirm: function () {
handle.close();
insertAtCursor(embedToken(
embedDest(absPath),
captionInput.value.trim(),
alignSelect.value
));
}
}
});
} }
}); });
} }
+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 // Wiki links are search queries, so the popup is the same typeahead the header
// fires only once the `[[` token's content begins with `/` (targets are // search box uses: the text typed after `[[` goes to `/_search` and each match
// absolute; free-text search lives in the toolbar modal instead). The content // is offered by name. Picking one replaces the query segment with that page's
// is split into a parent path (up to and including the last `/`) and a partial // name — never a path — so what lands in the document is still a query. The
// segment (the text after it); the parent's children are fetched from the // trailing `]]` and any `::display` tail are left untouched.
// existing `?tree=1` endpoint and filtered to names containing the partial
// (case-insensitive substring).
// //
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level // Exposes window.WikiComplete.source for main.js to register via CM's
// deeper; picking a file inserts `<name>` and stops. Only the current segment // autocompletion().
// is replaced, so the trailing `]]` stays put and the cursor parks before it,
// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
// for main.js to register via CM's autocompletion().
window.WikiComplete = (function () { window.WikiComplete = (function () {
// treeURL builds the `?tree=1` request URL for an absolute parent path, // Matches search-suggest.js so both surfaces fire at the same point.
// percent-encoding each segment. A leading/trailing slash is tolerated; var MIN_QUERY_LEN = 2;
// root resolves to `/?tree=1`.
function treeURL(parent) {
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
if (trimmed === '') return '/?tree=1';
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
return '/' + enc + '/?tree=1';
}
function fetchTree(parent) { function fetchSuggestions(query) {
return fetch(treeURL(parent), { return fetch('/_search?q=' + encodeURIComponent(query), {
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
}).then(function (r) { }).then(function (r) {
// A 404 means the parent folder doesn't exist (typo, or a path under
// a file) — treat it as "no completions", not an error.
if (r.status === 404) return null;
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json(); return r.json();
}); });
} }
// makeApply builds the apply() for a chosen entry. It replaces only the // queryEnd returns where the current query segment ends: the first `]`, the
// current segment ([from, to]); the trailing `]]` is untouched, so the // `::` that starts the display text, or end of line. Replacing only up to
// cursor ends up parked before it. Folders append `/` and re-open the popup // there keeps a re-edit from eating an existing alias or the closing `]]`.
// to drill into the next level; files terminate. function queryEnd(context, pos) {
function makeApply(name, kind) { var line = context.state.doc.lineAt(pos);
return function (view, completion, from, to) { var to = pos;
var isFolder = kind === 'folder'; while (to < line.to) {
var insert = isFolder ? name + '/' : name; var ch = context.state.sliceDoc(to, to + 1);
view.dispatch({ if (ch === ']') break;
changes: { from: from, to: to, insert: insert }, if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
selection: { anchor: from + insert.length }, to++;
scrollIntoView: true, }
}); return to;
if (isFolder) {
// Re-open after the transaction so the completion plugin sees
// the updated document (the next level's parent path).
setTimeout(function () { CM.startCompletion(view); }, 0);
}
};
} }
// CM completion source. Activates when `[[` is followed by content that // CM completion source. Activates on `[[` followed by at least
// begins with `/`. The content is split at its last `/` into a parent path // MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
// and a partial segment; the parent's children are fetched, filtered to // gets nothing.
// names containing the partial (case-insensitive substring), and offered
// with name-only labels.
function source(context) { function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/); var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null; if (!match) return null;
var content = context.state.sliceDoc(match.from + 2, context.pos); var content = context.state.sliceDoc(match.from + 2, context.pos);
if (content[0] !== '/') return null; if (content.indexOf('::') !== -1) return null;
var query = content.trim();
if (query.length < MIN_QUERY_LEN) return null;
var lastSlash = content.lastIndexOf('/'); var from = match.from + 2;
var parent = content.slice(0, lastSlash + 1); var to = queryEnd(context, context.pos);
var partial = content.slice(lastSlash + 1);
// Replace only the current segment: from the start of the partial up to
// the next `/` or `]` (or end of line). This narrows re-edits inside an
// existing `[[…]]` so drilling doesn't duplicate trailing text.
var from = match.from + 2 + lastSlash + 1;
var line = context.state.doc.lineAt(context.pos);
var to = context.pos;
while (to < line.to) {
var ch = context.state.sliceDoc(to, to + 1);
if (ch === '/' || ch === ']') break;
to++;
}
return new Promise(function (resolve) { return new Promise(function (resolve) {
if (context.aborted) { resolve(null); return; } if (context.aborted) { resolve(null); return; }
fetchTree(parent).then(function (resp) { fetchSuggestions(query).then(function (resp) {
if (context.aborted || !resp) { resolve(null); return; } if (context.aborted) { resolve(null); return; }
var needle = partial.toLowerCase(); var options = (resp.results || []).map(function (r) {
var options = (resp.entries || []).reduce(function (acc, e) { return { label: r.name, detail: '/' + r.path, apply: r.name };
if (needle && e.name.toLowerCase().indexOf(needle) === -1) { });
return acc; // filter:false — the server already ranked these fuzzily, and
} // CM's own prefix filter would drop the near-misses (typos,
acc.push({ // token reorderings) that make the fuzzy search worth having.
label: e.name, // No validFor either: every keystroke re-queries from scratch.
type: e.kind === 'folder' ? 'folder' : 'file', resolve({ from: from, to: to, options: options, filter: false });
apply: makeApply(e.name, e.kind),
});
return acc;
}, []);
// No validFor: the source re-runs on each keystroke, so every
// edit (more chars, backspace, or a `/` that drills into the
// next folder) re-fetches and re-filters from scratch.
resolve({ from: from, to: to, options: options });
}).catch(function () { }).catch(function () {
resolve(null); resolve(null);
}); });
+11
View File
@@ -56,3 +56,14 @@ document.addEventListener('keydown', function (e) {
menu.classList.remove('is-open'); 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="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script> <script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script> <script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script> {{if .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}} <script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}} {{block "headScripts" .}}{{end}}
</head> </head>
@@ -24,15 +24,25 @@
<nav class="breadcrumb row"> <nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a> <a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
</nav> </nav>
{{if not .EditMode}} {{if .ShowPageChrome}}
<form class="search-form" action="/" method="get"> <form class="search-form" action="/" method="get">
<input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> <input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
</form> </form>
{{end}} {{end}}
<div class="header-actions row">{{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> </header>
<div class="shell"> <div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar col"> {{if .ShowPageChrome}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div> <div class="tree-scroll"></div>
</aside>{{end}} </aside>{{end}}
<div class="center"> <div class="center">
@@ -48,7 +58,7 @@
</span> </span>
</footer> </footer>
</div> </div>
{{if not .EditMode}}<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>{{end}} {{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
</div> </div>
{{block "extras" .}}{{end}} {{block "extras" .}}{{end}}
</body> </body>
+3 -10
View File
@@ -1,8 +1,6 @@
// Full-viewport Overlay component (mobile). Distinct from the .modal dialog: // 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 // this hosts a whole sidebar at full screen with its own scroll and a top-right close control.
// full screen with its own scroll and a top-right close control. It reuses the // It reuses the low-level focus/Escape/bfcache patterns from modal.js rather than that component's panel chrome.
// 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 // 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 // body and restores it to its original DOM position on close, so the rail keeps
@@ -28,7 +26,7 @@
closeBtn = document.createElement('button'); closeBtn = document.createElement('button');
closeBtn.type = 'button'; closeBtn.type = 'button';
closeBtn.className = 'btn btn-fab overlay-close'; closeBtn.className = 'btn overlay-close';
closeBtn.title = 'Close'; closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close'); closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '×'; closeBtn.textContent = '×';
@@ -37,11 +35,6 @@
bodyEl = document.createElement('div'); bodyEl = document.createElement('div');
bodyEl.className = 'overlay-body'; 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) { bodyEl.addEventListener('click', function (e) {
if (e.target.closest('a')) close(); if (e.target.closest('a')) close();
}); });
+1 -16
View File
@@ -129,23 +129,9 @@ function movePage() {
input.placeholder = 'Page name'; input.placeholder = 'Page name';
input.value = currentName; input.value = currentName;
var linksCheckbox = document.createElement('input');
linksCheckbox.type = 'checkbox';
linksCheckbox.id = 'move-update-links';
var linksLabel = document.createElement('label');
linksLabel.htmlFor = linksCheckbox.id;
linksLabel.className = 'row';
linksLabel.appendChild(linksCheckbox);
linksLabel.appendChild(document.createTextNode('Update links'));
var body = document.createDocumentFragment();
body.appendChild(input);
body.appendChild(linksLabel);
openModal({ openModal({
title: 'Move — new name?', title: 'Move — new name?',
body: body, body: input,
confirm: { confirm: {
label: 'MOVE', label: 'MOVE',
onConfirm: function () { onConfirm: function () {
@@ -154,7 +140,6 @@ function movePage() {
var dest = (newParent === '/' ? '' : newParent) + '/' + name; var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' + var action = window.location.pathname + '?move=' +
encodeURIComponent(dest); encodeURIComponent(dest);
if (linksCheckbox.checked) action += '&links=1';
var target = encodePickedPath(dest) + '/'; var target = encodePickedPath(dest) + '/';
closeModal(); closeModal();
submitMove(action, target); submitMove(action, target);
-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}} {{define "headerActions"}}{{if .CanEdit}}
<span class="dropdown"> <button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
<button type="button" class="btn" data-action="page-actions" aria-haspopup="true" aria-expanded="false" title="Page actions">ACTIONS</button> <button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
<div class="dropdown-menu align-right"> {{if not .IsRoot}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button> <button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a> <button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button> {{end}}
{{if not .IsRoot}} <a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button> <button class="btn btn-block" onclick="newCanvas()" title="New canvas">NEW CANVAS</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>
{{end}}{{end}} {{end}}{{end}}
{{define "content"}} {{define "content"}}
{{/* Canvases in this folder are alternative views of the same page, so they
read as a tab strip above the content rather than as rows in the file
listing (listEntries filters them out for exactly this reason). */}}
{{if .Canvases}}
<nav class="canvas-tabs row" aria-label="Page views">
<span class="canvas-tab is-active">PAGE</span>
{{range .Canvases}}<a class="canvas-tab" href="{{.URL}}">{{.Name}}</a>{{end}}
{{if .CanEdit}}<button type="button" class="canvas-tab canvas-tab-add" onclick="newCanvas()" title="New canvas">+</button>{{end}}
</nav>
{{end}}
{{if .Content}} {{if .Content}}
<div class="content">{{.Content}}</div> <div class="content">{{.Content}}</div>
{{end}} {{end}}
@@ -57,14 +61,13 @@
{{end}} {{end}}
{{if or .Content .SpecialContent}} {{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script> <script src="/_/page/content.js"></script>
<script src="/_/page/anchors.js"></script>
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}} {{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
<script src="/_/page/tasks.js"></script> <script src="/_/page/tasks.js"></script>
{{end}} {{end}}
{{if .Content}} {{if .Content}}
<script src="/_/page/sections.js"></script> <script src="/_/page/sections.js"></script>
{{end}} {{end}}
<script src="/_/page/sidebar-fab.js"></script> <script src="/_/page/contents.js"></script>
{{end}} {{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'); var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (!headings.length) return; 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) { headings.forEach(function (h, i) {
if (h.querySelector('a.btn')) return; if (h.querySelector('a.btn')) return;
var a = document.createElement('a'); 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'; a.textContent = 'edit';
h.appendChild(document.createTextNode(' ')) h.appendChild(document.createTextNode(' '))
h.appendChild(a); 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; if (headings.length < 2) return;
var nav = document.createElement("nav"); var nav = document.createElement("nav");
nav.className = "toc panel panel-sidebar"; nav.className = "toc panel";
var header = document.createElement("div"); var header = document.createElement("div");
header.className = "panel-header"; header.className = "panel-header";
@@ -21,14 +21,12 @@ document.addEventListener("DOMContentLoaded", function () {
var a = document.createElement("a"); var a = document.createElement("a");
a.href = "#" + h.id; a.href = "#" + h.id;
var clone = h.cloneNode(true); 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(); a.textContent = clone.textContent.trim();
li.appendChild(a); li.appendChild(a);
list.appendChild(li); list.appendChild(li);
}); });
nav.appendChild(list); nav.appendChild(list);
// Stack the TOC on top of any server-rendered widget(s) already in the rail. content.insertBefore(nav, content.firstChild);
var rail = document.querySelector("aside.sidebar");
rail.insertBefore(nav, rail.firstChild);
}); });
-1
View File
@@ -53,7 +53,6 @@ function createSearchPage(name) {
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
var createLink = document.querySelector('[data-create-page]'); var createLink = document.querySelector('[data-create-page]');
if (createLink) { if (createLink) {
+4 -9
View File
@@ -42,15 +42,10 @@
{{end}} {{end}}
{{end}} {{end}}
{{define "headerActions"}}
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
{{end}}
{{define "footerExtras"}} {{define "footerExtras"}}
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}} {{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
{{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; --link-hover: #d6d24d;
--danger: #c40141; --danger: #c40141;
--danger-hover: #d03467; --danger-hover: #d03467;
/* Translucent scrim for badges/overlays sitting on top of media. */
--overlay: rgba(0, 0, 0, 0.55); --overlay: rgba(0, 0, 0, 0.55);
/* JSON Canvas preset colours "1".."6" (red, orange, yellow, green, cyan,
purple), pulled toward the app palette so a canvas does not look like a
different program. Hex colours in a .canvas file pass through untouched. */
--canvas-1: #e05555;
--canvas-2: #e08a3c;
--canvas-3: #d9b03c;
--canvas-4: #5aab64;
--canvas-5: #01b6c4;
--canvas-6: #a069c4;
--canvas-edge: #9a9a9a;
--border: 1px solid var(--secondary); --border: 1px solid var(--secondary);
--border-dashed: 1px dashed var(--secondary); --border-dashed: 1px dashed var(--secondary);
/* 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); --border-guide: 1px solid var(--bg-panel-hover);
--space-1: 0.25rem; --space-1: 0.25rem;
@@ -75,11 +83,9 @@ body {
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
a { color: var(--text); text-decoration: none; } 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 { color: var(--link); }
.content a:hover { color: var(--link-hover); } .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; } 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::after { content: "]"; color: var(--secondary); }
.btn:hover { color: var(--primary-hover); } .btn:hover { color: var(--primary-hover); }
.btn-small { font-size: 0.8rem; font-weight: normal; vertical-align: middle; } .btn-small { font-size: 0.8rem; font-weight: normal; vertical-align: middle; }
.btn-float {float: right;}
.btn-tool { padding: 0 0.15rem; } .btn-tool { padding: 0 0.15rem; }
.btn-block { .btn-block {
display: flex; display: flex;
@@ -219,22 +226,9 @@ footer {
padding: 0.3rem var(--space-3); padding: 0.3rem var(--space-3);
white-space: nowrap; white-space: nowrap;
} }
.btn-fab { .btn[hidden] {display: none;}
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); }
.danger { color: var(--danger); } .danger { color: var(--danger); }
.danger:hover { color: var(--danger-hover); } .danger:hover { color: var(--danger-hover); }
/* Selected segmented-toggle button (view-settings modal). */
.btn.is-active { color: var(--primary-hover); } .btn.is-active { color: var(--primary-hover); }
/* === Form controls === /* === Form controls ===
@@ -268,7 +262,7 @@ footer {
.content h4, .content h5, .content h6, .content h4, .content h5, .content h6,
main > h2 { main > h2 {
color: var(--text); color: var(--text);
margin: 1.25rem 0 var(--space-2); margin: var(--space-5) 0 var(--space-2);
line-height: 1.3; line-height: 1.3;
} }
.content h1 { .content h1 {
@@ -276,7 +270,12 @@ main > h2 {
border-bottom: var(--border-dashed); border-bottom: var(--border-dashed);
padding-bottom: var(--space-1); 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 h3 { font-size: 1.15rem; }
.content p { margin: var(--space-3) 0; } .content p { margin: var(--space-3) 0; }
.content ul, .content ol { margin: var(--space-3) 0 var(--space-3) var(--space-5); } .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; 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 === /* === Data tables ===
Shared style for the file listing, search-suggestion dropdown, and Shared style for the file listing, search-suggestion dropdown, and
markdown content tables. .data-table-grid adds per-cell borders + a 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.is-open { display: block; }
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; } .dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;}
/* === Suggestion dropdown (header search + editor link picker) === /* === Suggestion dropdown (header search + editor link picker) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
a dashed border and bg-panel-hover for the active row. */ 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); 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 === */
.companion-status { margin-left: auto; } .companion-status { margin-left: auto; }
.companion-icon { font-size: 0.9rem; line-height: 1; padding: 0.1rem 0.3rem; } .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); color: var(--secondary);
} }
.thumb-label { font-size: var(--font-sm); } .thumb-label { font-size: var(--font-sm); }
.empty { padding: var(--space-4); text-align: center; } .empty { padding: var(--space-4); text-align: center; }
/* === Scrollbars === */ /* === Optional Sidebar (right side) === */
::-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). */
.sidebar { .sidebar {
width: 14rem; width: 14rem;
flex-shrink: 0; flex-shrink: 0;
@@ -619,21 +591,23 @@ button.fab { display: none; }
border-left: var(--border-dashed); border-left: var(--border-dashed);
} }
aside.sidebar:empty { display: none; } 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 { .panel-sidebar {
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
font-size: var(--font-sm); font-size: var(--font-sm);
border: none; border: none;
} }
/* === Table of contents === /* === Table of contents ===*/
Rendered as a .panel-sidebar block in the right rail (padding + font-size .toc {
come from that density class); toc.js always mounts it there, on both float: right;
desktop and inside the mobile Overlay. */ 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 ul { list-style: none; margin: 0; padding: 0; }
.toc li { margin: 0.15rem 0; } .toc li { margin: 0.15rem 0; }
.toc a { .toc a {
@@ -706,7 +680,13 @@ aside.sidebar:empty { display: none; }
border-bottom: var(--border-dashed); border-bottom: var(--border-dashed);
flex-shrink: 0; 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 { .overlay-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -743,14 +723,13 @@ aside.sidebar:empty { display: none; }
border-left: 3px solid var(--primary); border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px); 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 { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; } .tree-row.is-disabled:hover { background: none; }
.tree-chevron { text-align: center; flex-shrink: 0; } .tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); } .tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; } .tree-chevron.is-leaf { visibility: hidden; }
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 { .tree-children {
margin-left: var(--space-2); margin-left: var(--space-2);
padding-left: var(--space-2); padding-left: var(--space-2);
@@ -761,24 +740,9 @@ aside.sidebar:empty { display: none; }
padding: var(--space-1) 0; padding: var(--space-1) 0;
word-break: break-all; 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. /* Tree sidebar (persistent navigation) */
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 { .tree-sidebar {
width: var(--tree-width); width: var(--tree-width);
flex-shrink: 0; flex-shrink: 0;
@@ -786,16 +750,12 @@ aside.sidebar:empty { display: none; }
padding: var(--space-4) 0 var(--space-4) var(--space-4); padding: var(--space-4) 0 var(--space-4) var(--space-4);
border-right: var(--border-dashed); 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 { .tree-scroll {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-right: var(--space-2); padding-right: var(--space-2);
font-size: var(--font-sm); 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; user-select: none;
} }
@@ -873,20 +833,25 @@ aside.sidebar:empty { display: none; }
/* === Responsive === */ /* === Responsive === */
@media (max-width: 1100px) { @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; } .tree-sidebar, .sidebar { display: none; }
/* Compact three-column header: logo left, search stretches the middle,
actions right. */
header { grid-template-columns: auto 1fr auto; } header { grid-template-columns: auto 1fr auto; }
.search-form { width: 100%; max-width: none; justify-self: stretch; } .search-form { width: 100%; max-width: none; justify-self: stretch; }
/* Reveal the mobile FAB stack (tree at the bottom, right rail above it). .actions-menu .btn-block {
The search actions dropdown FAB moves to the upper slot too so it never min-height: 2.75rem;
overlaps the tree FAB. */ padding: 0.8rem var(--space-4);
button.fab { display: inline-flex; } border-top: var(--border-dashed);
.fab.dropdown { bottom: calc(var(--space-4) + 3rem + var(--space-2)); } }
.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) { @media (max-width: 600px) {
@@ -894,10 +859,6 @@ aside.sidebar:empty { display: none; }
main { padding: var(--space-4) var(--space-3); } main { padding: var(--space-4) var(--space-3); }
.app-name {display: none;} .app-name {display: none;}
.editor-cm { min-height: 50vh; } .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 { gap: var(--space-2); padding: var(--space-2); }
.editor-toolbar .btn-tool { .editor-toolbar .btn-tool {
display: inline-flex; display: inline-flex;
@@ -907,17 +868,10 @@ aside.sidebar:empty { display: none; }
min-height: 2rem; min-height: 2rem;
padding: 0 var(--space-1); 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 { body.edit-mode .editor-toolbar {
position: fixed; position: fixed;
left: 0; left: 0;
right: 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; top: auto;
bottom: 0; bottom: 0;
z-index: 50; z-index: 50;
@@ -928,6 +882,7 @@ aside.sidebar:empty { display: none; }
body.edit-mode .cm-content { body.edit-mode .cm-content {
padding-bottom: calc(5rem + env(safe-area-inset-bottom)); 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-backdrop { padding: var(--space-2); align-items: flex-start; }
.modal { max-width: none; margin-top: var(--space-4); } .modal { max-width: none; margin-top: var(--space-4); }
.modal .panel-header { cursor: default; } .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.icon { grid-row: 1; grid-column: 1; }
.list-item td.name { grid-row: 1; grid-column: 2; } .list-item td.name { grid-row: 1; grid-column: 2; }
.list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; } .list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; }
.toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);}
}
/* === 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 ;(function () {
// to the current page's ancestor chain, and lets the user navigate by clicking // This script owns the .tree-scroll child of the aside, not the aside itself.
// folders (links) or open files locally via the companion (a.tree-file, wired var container = document.querySelector("aside.tree-sidebar .tree-scroll")
// in companion.js). Shares the .tree-* CSS and the ?tree endpoint with if (!container) return
// 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 () { function joinPath(parent, name) {
// This script owns the .tree-scroll child of the aside, not the aside itself. if (parent === "/" || parent === "") return "/" + name
var container = document.querySelector('aside.tree-sidebar .tree-scroll'); return parent.replace(/\/+$/, "") + "/" + name
if (!container) return; }
function joinPath(parent, name) { function encodeSegments(p) {
if (parent === '/' || parent === '') return '/' + name; return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
return parent.replace(/\/+$/, '') + '/' + name; }
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) { var preChildren = entry.children ? entry.children : null
return p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); 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) { function markLeaf() {
if (p === '/' || p === '') return '/'; isLeaf = true
return '/' + encodeSegments(p) + '/'; isOpen = false
chevron.textContent = "▸"
chevron.classList.add("is-leaf")
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
} }
function fileHref(p) { function loadChildren() {
return '/' + encodeSegments(p); 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) { function open() {
var url = folderHref(path) + '?tree=1'; if (isOpen || isLeaf) return
if (expandTo) url += '&expandTo=' + encodeURIComponent(expandTo); var el = ensureChildrenEl()
return fetch(url, { credentials: 'same-origin' }).then(function (r) { row.parentNode.insertBefore(el, row.nextSibling)
if (!r.ok) throw new Error('HTTP ' + r.status); chevron.textContent = "▾" // ▾
return r.json(); isOpen = true
}); if (!loaded) {
} if (preChildren) {
renderInto(el, fullPath, preChildren)
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing loaded = true
// slash otherwise). pathname is percent-encoded; the tree's entry names (and } else {
// thus fullPath) are decoded, so we must decode here too or paths containing loadChildren()
// 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;
} }
entries.forEach(function (entry) { }
var built = buildRow(parentPath, entry);
containerEl.appendChild(built.row);
if (built.preExpand) built.open();
});
} }
function buildRow(parentPath, entry) { function close() {
var fullPath = joinPath(parentPath, entry.name); if (!isOpen) return
var isFolder = entry.kind === 'folder'; if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
var row = document.createElement('div'); }
row.className = 'tree-row'; chevron.textContent = "▸"
isOpen = false
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 render() { // Clicking the name link navigates (folder) or opens the file; clicking
container.textContent = ''; // anywhere else on the row toggles expansion for folders.
var listing = document.createElement('div'); row.addEventListener("click", function (e) {
container.appendChild(listing); if (e.target.closest("a.tree-name")) return
listing.appendChild(disabledRow('…')); if (isOpen) close()
else open()
})
fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) { return { row: row, open: open, preExpand: !!preChildren }
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);
});
}
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and function render() {
// opens the tree's scroll container in the full-viewport Overlay container.textContent = ""
// (overlay.js). The container always exists when not editing, so — per the var listing = document.createElement("div")
// layout spec — the FAB always renders; the overlay auto-closes when a container.appendChild(listing)
// folder/file link inside it navigates. Hidden on desktop by .fab CSS. listing.appendChild(disabledRow("…"))
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);
}
render(); fetchFolder("/", activePath === "/" ? "" : activePath)
setupFab(); .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" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -208,22 +207,6 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia
return 0, "", "", false 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 // findSectionIndex returns the absolute section index whose heading text
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match. // matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
func findSectionIndex(sections [][]byte, target string) (int, bool) { func findSectionIndex(sections [][]byte, target string) (int, bool) {
+91 -159
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"path" "path"
"regexp" "strconv"
"strings" "strings"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@@ -14,105 +14,95 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader. // Embeds are ordinary Markdown images: ![caption](photo.jpg "left").
// The inner blob forbids brackets and newlines but allows ':' so the whole //
// target::caption::align payload travels as one capture — the caption and // Destinations are relative to the page they sit on. Pages always render at a
// alignment tail is split apart in Go (parseEmbedFields). This mirrors // URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes. // resolves the destination and nothing here builds a URL or touches the
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`) // filesystem. Goldmark's own image parser produces the nodes; only rendering
// is overridden. The title slot carries the float alignment.
const ( const (
alignLeft = "left" alignLeft = "left"
alignRight = "right" alignRight = "right"
alignCenter = "center" alignCenter = "center"
// embedThumbW is the width embeds request through the ?w= form of the
// file's own URL, which keeps the <img src> relative.
embedThumbW = 300
) )
// wikiEmbedNode is the AST node produced by wikiEmbedParser. // embedAlign maps an image title to its float class. A title that is not an
type wikiEmbedNode struct { // alignment keyword is ignored and the embed floats right.
ast.BaseInline func embedAlign(title []byte) string {
Target string switch strings.TrimSpace(string(title)) {
Caption string
Align string
}
var kindWikiEmbed = ast.NewNodeKind("WikiEmbed")
func (n *wikiEmbedNode) Kind() ast.NodeKind { return kindWikiEmbed }
func (n *wikiEmbedNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": n.Target,
"Caption": n.Caption,
"Align": n.Align,
}, nil)
}
// alignKeyword reports whether s (trimmed) is one of the alignment keywords.
func alignKeyword(s string) (string, bool) {
switch strings.TrimSpace(s) {
case alignLeft: case alignLeft:
return alignLeft, true return alignLeft
case alignRight:
return alignRight, true
case alignCenter: case alignCenter:
return alignCenter, true return alignCenter
} }
return "", false return alignRight
} }
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and // embedIsImage reports whether a destination names an image, i.e. whether it
// alignment per the embed syntax rules. The target is the field before the // renders as a <figure> rather than degrading to a link. Extension-only — no
// first "::". The trailing "::"-fields are interpreted as: // stat, so a destination gone stale simply 404s when clicked.
// - none: no caption, default alignment (right) func embedIsImage(dest []byte) bool {
// - one keyword field: that alignment, no caption return isImageFile(path.Base(string(dest)))
// - one non-keyword field: that caption, default alignment }
// - two or more: the last field is the alignment slot (default when it is not
// a keyword) and the earlier fields re-join with "::" as the caption, so a type embedRenderer struct{}
// caption may contain a literal "::" as long as an alignment field trails it.
func parseEmbedFields(inner string) (target, caption, align string) { func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
fields := strings.Split(inner, "::") reg.Register(ast.KindImage, r.render)
target = strings.TrimSpace(fields[0]) }
tail := fields[1:]
align = alignRight // render writes a <figure> holding a thumbnail that links to the full file,
switch len(tail) { // with the image's inline children as <figcaption>. Children are rendered by
case 0: // goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
// target only // emphasis or a link. A non-image destination renders as a plain anchor
case 1: // labelled with the caption, or with the file name when there is none.
if kw, ok := alignKeyword(tail[0]); ok { func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
align = kw n := node.(*ast.Image)
name := path.Base(string(n.Destination))
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
captioned := n.FirstChild() != nil
if !embedIsImage(n.Destination) {
if entering {
w.WriteString(`<a href="`)
w.Write(dest)
w.WriteString(`">`)
if !captioned {
w.Write(util.EscapeHTML([]byte(name)))
}
} else { } else {
caption = strings.TrimSpace(tail[0]) w.WriteString(`</a>`)
} }
default: return ast.WalkContinue, nil
if kw, ok := alignKeyword(tail[len(tail)-1]); ok { }
align = kw
if entering {
w.WriteString(`<figure class="embed embed-`)
w.WriteString(embedAlign(n.Title))
w.WriteString(`"><a class="embed-link" href="`)
w.Write(dest)
w.WriteString(`"><img src="`)
w.Write(dest)
w.WriteString(`?w=`)
w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
} }
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::")) return ast.WalkContinue, nil
} }
return target, caption, align if captioned {
} w.WriteString(`</figcaption>`)
type wikiEmbedParser struct{}
func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
// Require the exact "![[" 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
} }
m := wikiEmbedRe.FindSubmatchIndex(line) w.WriteString(`</figure>`)
if m == nil { return ast.WalkContinue, nil
return nil
}
target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
if !isValidWikiTarget([]byte(target)) {
return nil
}
block.Advance(m[1])
return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
} }
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to // embedBlockTransformer lifts an embed that sits alone in a paragraph up to
@@ -122,7 +112,7 @@ func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Co
// and breaks the float layout once several embeds share a page. Dissolving the // and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no // wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>. // stray <p>.
type embedBlockTransformer struct{ root string } type embedBlockTransformer struct{}
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source() source := reader.Source()
@@ -144,7 +134,7 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
// block sibling ahead of the (soon removed) paragraph, then drop p. // block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; { for c := p.FirstChild(); c != nil; {
next := c.NextSibling() next := c.NextSibling()
if _, ok := c.(*wikiEmbedNode); ok { if _, ok := c.(*ast.Image); ok {
parent.InsertBefore(parent, p, c) parent.InsertBefore(parent, p, c)
} }
c = next c = next
@@ -154,16 +144,16 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
} }
// paragraphFiguresOnly reports whether p holds at least one figure-rendering // paragraphFiguresOnly reports whether p holds at least one figure-rendering
// embed and nothing else visible — only such embeds and whitespace/line-break // image and nothing else visible — only such images and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed // text. Only these are safe to dissolve: a paragraph carrying prose, or an
// that degrades to an inline link (missing / non-image target), stays wrapped so // image that degrades to an inline link (non-image destination), stays wrapped
// the fallback anchor keeps its paragraph. // so the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool { func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() { for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) { switch n := c.(type) {
case *wikiEmbedNode: case *ast.Image:
if !embedIsImage(t.root, n.Target) { if !embedIsImage(n.Destination) {
return false return false
} }
hasFigure = true hasFigure = true
@@ -178,77 +168,19 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
return hasFigure return hasFigure
} }
// embedIsImage reports whether target resolves to an existing image file, i.e. type embedExt struct{}
// the embed will render as a <figure> rather than degrade to a link fallback.
func embedIsImage(root, target string) bool {
name := path.Base(normalizeWikiTarget(target))
return wikiTargetExists(root, target) && isImageFile(name)
}
type wikiEmbedRenderer struct { // newEmbedExt returns a goldmark extension that renders Markdown images as
root string // wiki embeds. It adds no parser of its own: goldmark parses the images, and
} // the renderer registered at 500 wins over the default HTML renderer
// (registered at 1000 — lowest priority number is applied last and wins).
func newEmbedExt() goldmark.Extender { return &embedExt{} }
func (r *wikiEmbedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { func (e *embedExt) Extend(m goldmark.Markdown) {
reg.Register(kindWikiEmbed, r.render)
}
func (r *wikiEmbedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*wikiEmbedNode)
// Embed only when the target exists and is an image we can thumbnail.
// Anything else — a missing target, or an existing non-image file — degrades
// to the same anchor a plain [[wikilink]] would render (broken or working).
if !embedIsImage(r.root, n.Target) {
writeWikiLink(w, r.root, n.Target, "")
return ast.WalkContinue, nil
}
fileHref := wikiFileHref(n.Target)
alt := n.Caption
if alt == "" {
alt = path.Base(normalizeWikiTarget(n.Target))
}
w.WriteString(`<figure class="embed embed-`)
w.WriteString(n.Align)
w.WriteString(`"><a class="embed-link" href="`)
w.WriteString(fileHref)
w.WriteString(`"><img src="`)
w.WriteString(thumbURL(fileHref, 300))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(alt)))
w.WriteString(`"></a>`)
if n.Caption != "" {
w.WriteString(`<figcaption>`)
w.Write(util.EscapeHTML([]byte(n.Caption)))
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
type wikiEmbedExt struct{ root string }
// newWikiEmbedExt returns a goldmark extension that turns ![[...]] tokens into
// image embeds resolved against root.
func newWikiEmbedExt(root string) goldmark.Extender {
return &wikiEmbedExt{root: root}
}
func (e *wikiEmbedExt) Extend(m goldmark.Markdown) {
// Priority 199 — one higher than the default image parser (200) so ![[...]]
// is consumed as an embed before the default `!` parser sees it.
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(&wikiEmbedParser{}, 199),
))
m.Parser().AddOptions(parser.WithASTTransformers( m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{root: e.root}, 100), util.Prioritized(embedBlockTransformer{}, 100),
)) ))
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500), util.Prioritized(&embedRenderer{}, 500),
)) ))
} }
-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" "io/fs"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -47,6 +48,7 @@ var (
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html")) pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html")) editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html")) searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/canvas/main.html"))
) )
// specialPage is the result returned by a pageTypeHandler. // specialPage is the result returned by a pageTypeHandler.
@@ -105,7 +107,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
initMarkdown(root) initMarkdown()
authKey, err := loadOrCreateAuthKey(root) authKey, err := loadOrCreateAuthKey(root)
if err != nil { if err != nil {
@@ -124,6 +126,7 @@ func main() {
http.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex) http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest) http.HandleFunc("/_search", h.handleSearchSuggest)
http.HandleFunc("/_canvas/render", h.handleCanvasRender)
http.HandleFunc("/quickadd", h.handleQuickAdd) http.HandleFunc("/quickadd", h.handleQuickAdd)
http.Handle("/", h) http.Handle("/", h)
@@ -212,6 +215,16 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// A canvas is addressed as a view of its folder (?canvas=Name), so a direct
// hit on the file collapses onto that canonical form — the same shape as
// diary.go's virtual-URL redirects. ?raw keeps the bytes reachable.
if r.Method == http.MethodGet && strings.HasSuffix(urlPath, canvasExt) && !r.URL.Query().Has("raw") {
if target, ok := canvasViewURL(urlPath); ok {
http.Redirect(w, r, target, http.StatusFound)
return
}
}
info, err := os.Stat(fsPath) info, err := os.Stat(fsPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -220,7 +233,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// POSTs must not be redirected — the form action has no trailing // POSTs must not be redirected — the form action has no trailing
// slash (path.Clean strips it) and the content would be lost. // slash (path.Clean strips it) and the content would be lost.
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost { if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
return return
} }
h.serveDir(w, r, urlPath, fsPath) h.serveDir(w, r, urlPath, fsPath)
@@ -232,14 +245,39 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if info.IsDir() { if info.IsDir() {
if urlPath != "/" { if urlPath != "/" {
// Pages must live at a slash-terminated URL: embed destinations are
// relative, so "/Trips/Japan" would resolve them against "/Trips/".
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
return
}
urlPath += "/" urlPath += "/"
} }
h.serveDir(w, r, urlPath, fsPath) h.serveDir(w, r, urlPath, fsPath)
} else { } else {
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so
// their <img src> can stay relative to the page; /_thumb/<abs path>
// stays for listings, which have absolute paths on hand anyway. A file
// with no thumbnailer ignores the parameter and serves normally.
if r.URL.Query().Has("w") && hasThumbnail(fsPath) {
h.serveThumbnail(w, r, fsPath)
return
}
http.ServeFile(w, r, fsPath) http.ServeFile(w, r, fsPath)
} }
} }
// slashedURL returns u's path with a trailing slash appended and the query
// string intact — the canonical page form that relative destinations resolve
// against.
func slashedURL(u *url.URL) string {
target := u.Path + "/"
if u.RawQuery != "" {
target += "?" + u.RawQuery
}
return target
}
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
_, editMode := r.URL.Query()["edit"] _, editMode := r.URL.Query()["edit"]
@@ -248,6 +286,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
return return
} }
// An explicit ?canvas= selects this page's canvas view. Checked ahead of
// the pageTypeHandler redirect loop so an explicit view request always wins
// over a folder-type redirect (e.g. a canvas kept in a diary year folder).
//
// This is deliberately not a pageTypeHandler: that interface injects content
// into the standard page body, while a canvas replaces the whole body with a
// full-bleed surface and its own controls — a view mode like ?edit, which is
// likewise a template branch here rather than a handler.
if name := r.URL.Query().Get("canvas"); name != "" {
h.serveCanvas(w, r, urlPath, fsPath, name)
return
}
for _, ph := range pageTypeHandlers { for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok { if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
http.Redirect(w, r, target, http.StatusFound) http.Redirect(w, r, target, http.StatusFound)
@@ -261,6 +312,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
// Determine section index (-1 = whole page). // Determine section index (-1 = whole page).
sectionIndex := -1 sectionIndex := -1
insertBefore := -1 insertBefore := -1
sectionSpan := false
if editMode { if editMode {
if s := r.URL.Query().Get("section"); s != "" { if s := r.URL.Query().Get("section"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 { 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 insertBefore = n
} }
} }
sectionSpan = r.URL.Query().Has("span")
} }
var special *specialPage var special *specialPage
@@ -288,6 +341,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
rendered = renderMarkdown(rawMD) rendered = renderMarkdown(rawMD)
} }
// Canvases in this folder surface as a view switcher above the content, so
// a page and its diagrams read as one thing. Suppressed in the editor, which
// is about the markdown only.
var canvases []canvasRef
if !editMode {
canvases = listCanvases(fsPath, urlPath, "")
}
view, sortKey, order := readPageSettings(fsPath).viewSettings() view, sortKey, order := readPageSettings(fsPath).viewSettings()
var entries []entry var entries []entry
if !editMode && (special == nil || !special.SuppressListing) { if !editMode && (special == nil || !special.SuppressListing) {
@@ -323,7 +384,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
} else if editMode && sectionIndex >= 0 { } else if editMode && sectionIndex >= 0 {
sections := splitSections(rawMD) sections := splitSections(rawMD)
if sectionIndex < len(sections) { 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 != "/" { } else if editMode && rawContent == "" && urlPath != "/" {
rawContent = "# " + pageTitle(urlPath) + "\n\n" rawContent = "# " + pageTitle(urlPath) + "\n\n"
@@ -336,6 +401,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
IsRoot: urlPath == "/", IsRoot: urlPath == "/",
SectionIndex: sectionIndex, SectionIndex: sectionIndex,
InsertBefore: insertBefore, InsertBefore: insertBefore,
Span: sectionSpan,
PostURL: urlPath, PostURL: urlPath,
RawContent: rawContent, RawContent: rawContent,
Content: rendered, Content: rendered,
@@ -346,6 +412,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
SpecialContent: specialContent, SpecialContent: specialContent,
SidebarWidget: sidebarWidget, SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC, SuppressTOC: suppressTOC,
Canvases: canvases,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -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) { func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
query := r.URL.Query() query := r.URL.Query()
// Checked first so ?canvas=X&delete removes that canvas rather than falling
// through to the page-level delete below.
if name := query.Get("canvas"); name != "" {
h.handleCanvasPost(w, r, urlPath, fsPath, name)
return
}
if query.Has("delete") { if query.Has("delete") {
h.handleDelete(w, r, urlPath, fsPath) h.handleDelete(w, r, urlPath, fsPath)
return return
} }
if _, ok := query["move"]; ok { if _, ok := query["move"]; ok {
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"), query.Has("merge")) h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
return return
} }
if query.Has("toggle") { if query.Has("toggle") {
@@ -390,11 +463,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
indexPath := filepath.Join(fsPath, "index.md") indexPath := filepath.Join(fsPath, "index.md")
redirectTarget := urlPath redirectTarget := urlPath
// insert_before splices a new section into the file *at* index N rather // handle section saving
// 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.
if s := r.FormValue("insert_before"); s != "" { if s := r.FormValue("insert_before"); s != "" {
insertIndex, err := strconv.Atoi(s) insertIndex, err := strconv.Atoi(s)
if err != nil || insertIndex < 0 { 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) http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
return 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 // Section index ≥ 1 is a heading-anchored section. Redirect to its
// anchor so the user lands on the section they just saved, even if // anchor so the user lands on the section they just saved, even if
// the heading text changed. // the heading text changed.
+5 -118
View File
@@ -1,19 +1,14 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When // handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
// updateLinks is true it also rewrites every [[...]] wiki link across the
// tree that targets the old path or any descendant; rewritten files are held
// in memory for rollback.
// //
// If the destination folder already exists it is normally a hard conflict. // If the destination folder already exists it is normally a hard conflict.
// The one exception is a merge: when the source carries a page (index.md) and // The one exception is a merge: when the source carries a page (index.md) and
@@ -21,7 +16,7 @@ import (
// container without clobbering anything. That merge only proceeds when merge // container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the // is true; otherwise the client is told a merge is available so it can ask the
// user to confirm. // user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) { func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
oldPath := normalizeMovePath(srcURL) oldPath := normalizeMovePath(srcURL)
if oldPath == "/" { if oldPath == "/" {
http.Error(w, "cannot move wiki root", http.StatusBadRequest) http.Error(w, "cannot move wiki root", http.StatusBadRequest)
@@ -74,69 +69,32 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
merging = true merging = true
} }
// Phase 1: optionally walk the tree and rewrite every index.md that // Phase 1: create intermediate parent folders for the destination.
// references the moved path. Keep the pre-rewrite bytes in memory so we
// can revert on failure. The walker only reads directory listings and
// files literally named index.md; hidden directories are pruned. A cheap
// substring check skips parsing files that cannot contain a relevant
// link.
rewritten := map[string][]byte{}
if updateLinks {
needle := []byte("[[" + oldPath)
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
orig, err := os.ReadFile(fsPath)
if err != nil {
return err
}
if !bytes.Contains(orig, needle) {
return nil
}
updated, changed := rewriteWikiLinks(orig, oldPath, newPath)
if !changed {
return nil
}
if err := writeFileAtomic(fsPath, updated, 0644); err != nil {
return fmt.Errorf("write %s: %w", fsPath, err)
}
rewritten[fsPath] = orig
return nil
})
if walkErr != nil {
rollbackRewrites(rewritten)
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
return
}
}
// Phase 2: create intermediate parent folders for the destination.
if parent := filepath.Dir(dstFsPath); parent != "" { if parent := filepath.Dir(dstFsPath); parent != "" {
if err := os.MkdirAll(parent, 0755); err != nil { if err := os.MkdirAll(parent, 0755); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
} }
// Phase 3: move the source into place. A plain move renames the whole // Phase 2: move the source into place. A plain move renames the whole
// folder; a merge moves the source's entries into the existing // folder; a merge moves the source's entries into the existing
// destination and drops the emptied source. // destination and drops the emptied source.
if merging { if merging {
if err := mergeFolder(srcFsPath, dstFsPath); err != nil { if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else { } else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil { if err := os.Rename(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} }
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther) http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
} }
// canMergeMove reports whether moving onto an existing destination should // canMergeMove reports whether moving onto an existing destination should
@@ -248,77 +206,6 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
return "/" + trimmed, nil return "/" + trimmed, nil
} }
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
// [[target::display]] whose target equals oldPath or begins with oldPath+"/"
// has its target rewritten to the corresponding position under newPath.
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
changed := false
out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte {
parts := wikiLinkPattern.FindSubmatch(match)
if parts == nil {
return match
}
target := strings.TrimSpace(string(parts[1]))
normTarget := normalizeMovePath(target)
var newTarget string
switch {
case normTarget == oldPath:
newTarget = newPath
case strings.HasPrefix(normTarget, oldPath+"/"):
newTarget = newPath + strings.TrimPrefix(normTarget, oldPath)
default:
return match
}
changed = true
suffix := ""
if len(parts[2]) > 0 {
suffix = "::" + string(parts[2])
}
return []byte("[[" + newTarget + suffix + "]]")
})
return out, changed
}
// rollbackRewrites restores the given files to their pre-rewrite contents.
// Errors are logged; best-effort since we're already in a failure path.
func rollbackRewrites(rewritten map[string][]byte) {
for path, orig := range rewritten {
if err := writeFileAtomic(path, orig, 0644); err != nil {
log.Printf("rollback %s: %v", path, err)
}
}
}
// walkIndexFiles visits every `index.md` under root, skipping hidden
// directories (names beginning with `.`). Unlike filepath.WalkDir this does
// not stat each regular file — on spinning disks that saves the bulk of the
// traversal cost when folders contain many non-page files (photos, archives).
func walkIndexFiles(root string, visit func(fsPath string) error) error {
entries, err := os.ReadDir(root)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
full := filepath.Join(root, name)
if e.IsDir() {
if err := walkIndexFiles(full, visit); err != nil {
return err
}
continue
}
if name == "index.md" {
if err := visit(full); err != nil {
return err
}
}
}
return nil
}
// writeFileAtomic writes data to a temp file in the same directory as path // writeFileAtomic writes data to a temp file in the same directory as path
// and renames it into place so readers never observe a partial file. // and renames it into place so readers never observe a partial file.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error { func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+45 -5
View File
@@ -19,12 +19,12 @@ import (
var md goldmark.Markdown var md goldmark.Markdown
// initMarkdown builds the package-level goldmark instance. Called once from // initMarkdown builds the package-level goldmark instance. Neither extension
// main after the wiki root is known so the wiki-link extension can resolve // resolves anything against the filesystem — wiki links are search queries and
// targets against the filesystem. // embed destinations are relative to the page — so this needs no wiki root.
func initMarkdown(root string) { func initMarkdown() {
md = goldmark.New( md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root), newExtLinksExt()), goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
goldmark.WithParserOptions(parser.WithAutoHeadingID()), goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()), goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
) )
@@ -52,6 +52,7 @@ type pageData struct {
IsRoot bool IsRoot bool
SectionIndex int // -1 = whole page; >=0 = section being edited SectionIndex int // -1 = whole page; >=0 = section being edited
InsertBefore int // -1 = no insert; >=0 = splice new section at this index 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 PostURL string
RawContent string RawContent string
Content template.HTML Content template.HTML
@@ -63,6 +64,39 @@ type pageData struct {
SidebarWidget template.HTML SidebarWidget template.HTML
SuppressTOC bool SuppressTOC bool
RenderMS int64 RenderMS int64
// Canvases lists every .canvas file in this folder as a view-switcher tab.
// ActiveCanvas names the one currently being viewed ("" on a normal page),
// and is what makes this page render as a canvas rather than as markdown.
Canvases []canvasRef
ActiveCanvas string
// CanvasJSON is the whole canvas document plus its server-rendered node
// HTML, inlined into the page so opening a canvas costs one request.
CanvasJSON template.JS
// CanvasError carries a parse/validation failure to the template, which
// shows it in place of the canvas instead of failing the request.
CanvasError string
}
// layoutData is the contract every view built on assets/layout.html satisfies.
// The layout is shared by pageData and searchPageData, and a method only one of
// them has fails at render time with a half-written response rather than at
// build time — so each is asserted against the interface here.
type layoutData interface {
ShowPageChrome() bool
}
var (
_ layoutData = pageData{}
_ layoutData = searchPageData{}
)
// ShowPageChrome reports whether the standard page furniture — search box,
// folder-tree rail, right sidebar, and the collapsed ACTIONS dropdown — should
// render. The editor and the canvas view both take the full width and supply
// their own controls, so both opt out.
func (d pageData) ShowPageChrome() bool {
return !d.EditMode && d.ActiveCanvas == ""
} }
// Allowed values for the listing view settings. Unknown values in the file or // Allowed values for the listing view settings. Unknown values in the file or
@@ -129,6 +163,7 @@ var (
iconAudio = readIcon("audio") iconAudio = readIcon("audio")
iconArchive = readIcon("archive") iconArchive = readIcon("archive")
iconGeneric = readIcon("generic") iconGeneric = readIcon("generic")
iconCanvas = readIcon("canvas")
) )
// renderMarkdown converts raw markdown to trusted HTML. // renderMarkdown converts raw markdown to trusted HTML.
@@ -210,6 +245,9 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
if name == "index.md" { if name == "index.md" {
continue // rendered above, don't list it continue // rendered above, don't list it
} }
if isCanvasFile(name) {
continue // surfaced as a view-switcher tab above the content
}
f := entry{ f := entry{
Icon: fileIcon(name), Icon: fileIcon(name),
Name: name, Name: name,
@@ -299,6 +337,8 @@ func readIcon(name string) template.HTML {
func fileIcon(name string) template.HTML { func fileIcon(name string) template.HTML {
ext := strings.ToLower(path.Ext(name)) ext := strings.ToLower(path.Ext(name))
switch ext { switch ext {
case ".canvas":
return iconCanvas
case ".md", ".pdf": case ".md", ".pdf":
return iconDoc return iconDoc
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
+5
View File
@@ -49,6 +49,11 @@ type searchPageData struct {
RenderMS int64 RenderMS int64
} }
// ShowPageChrome satisfies the shared layout's contract (see layoutData). The
// results page is always ordinary chrome — it is never the editor and never a
// canvas — but EditMode is honoured so the two views cannot drift apart.
func (d searchPageData) ShowPageChrome() bool { return !d.EditMode }
// indexEntry is the shared scoreable core of both folder and file index // indexEntry is the shared scoreable core of both folder and file index
// entries: a forward-slash relative path plus its pre-tokenized basename so // entries: a forward-slash relative path plus its pre-tokenized basename so
// the per-query scoring loop avoids redoing the lowercasing and tokenization // the per-query scoring loop avoids redoing the lowercasing and tokenization
+34
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"regexp" "regexp"
"strings"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text" "github.com/yuin/goldmark/text"
@@ -10,6 +11,11 @@ import (
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `) 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. // splitSections splits raw markdown into sections.
// Section 0 is any content before the first heading. // Section 0 is any content before the first heading.
// Each subsequent section begins at a heading line and runs to the next. // 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() 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) http.Error(w, "bad path", http.StatusBadRequest)
return return
} }
cleanPath := path.Clean(decoded) h.serveThumbnail(w, r, filepath.Join(h.root, filepath.FromSlash(path.Clean(decoded))))
}
srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath)) // serveThumbnail generates (or serves from cache) a thumbnail of the file at
// srcFS, sized by the ?w= query. Both entry points land here: the /_thumb
// route used by listings and the diary, and the ?w= query on a file's own URL,
// which is the form embeds use so their <img src> stays relative to the page.
func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS string) {
rel, err := filepath.Rel(h.root, srcFS) rel, err := filepath.Rel(h.root, srcFS)
if err != nil || strings.HasPrefix(rel, "..") { if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
+10 -12
View File
@@ -40,18 +40,16 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
entries, err := listTreeEntries(fsPath) foldersOnly := r.URL.Query().Has("foldersOnly")
entries, err := listTreeEntries(fsPath, foldersOnly)
if err != nil { if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError) http.Error(w, "read failed", http.StatusInternalServerError)
return 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 != "" { 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} 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 // listTreeEntries returns the immediate children of fsPath, filtering hidden
// entries and index.md. Folders are listed before files; both groups are // entries and index.md. Folders are listed before files; both groups are
// sorted alphabetically. // sorted alphabetically. foldersOnly will not list any individual files at all.
func listTreeEntries(fsPath string) ([]treeEntry, error) { func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
raw, err := os.ReadDir(fsPath) raw, err := os.ReadDir(fsPath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -85,7 +83,7 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
if e.IsDir() { if e.IsDir() {
folders = append(folders, treeEntry{Name: name, Kind: "folder"}) folders = append(folders, treeEntry{Name: name, Kind: "folder"})
} else { } else {
if name == "index.md" { if foldersOnly || name == "index.md" {
continue continue
} }
files = append(files, treeEntry{Name: name, Kind: "file"}) 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 // far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the // listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree. // listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string) { func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 { if len(segs) == 0 {
return return
} }
@@ -122,12 +120,12 @@ func expandTreeChain(fsPath string, entries []treeEntry, segs []string) {
continue continue
} }
childFs := filepath.Join(fsPath, segs[0]) childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs) kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil { if err != nil {
return return
} }
entries[i].Children = kids entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:]) expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return return
} }
} }
+48 -116
View File
@@ -3,8 +3,6 @@ package main
import ( import (
"bytes" "bytes"
"net/url" "net/url"
"os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -16,20 +14,17 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start // wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
// of the current inline reader. Target and display forbid newlines and // the current inline reader. Query and display forbid newlines and brackets;
// brackets; the target is non-greedy so the first `::` separates target from // the query is non-greedy so the first `::` separates query from display when
// display when both are present. // both are present.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`) var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source. // wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
// Used by the move-endpoint rewriter; not by the goldmark parser. // normalized by wikiSearchQuery.
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkNode is the AST node produced by wikiLinkParser.
type wikiLinkNode struct { type wikiLinkNode struct {
ast.BaseInline ast.BaseInline
Target []byte Query []byte
Display []byte Display []byte
} }
@@ -39,27 +34,32 @@ func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
func (n *wikiLinkNode) Dump(source []byte, level int) { func (n *wikiLinkNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{ ast.DumpHelper(n, source, level, map[string]string{
"Target": string(n.Target), "Query": string(n.Query),
"Display": string(n.Display), "Display": string(n.Display),
}, nil) }, nil)
} }
// isValidWikiTarget rejects targets that are not absolute or that contain // wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
// traversal / empty segments. Matches the validation used by the move endpoint. // A leading `/` marks a link written under the old path-based syntax; those
func isValidWikiTarget(target []byte) bool { // collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
if len(target) == 0 || target[0] != '/' { // existing content keeps resolving without a rewrite. Everything else is free
return false // text and passes through with only surrounding space trimmed.
func wikiSearchQuery(raw string) string {
q := strings.TrimSpace(raw)
if !strings.HasPrefix(q, "/") {
return q
} }
trimmed := strings.Trim(string(target), "/") trimmed := strings.Trim(q, "/")
if trimmed == "" { if i := strings.LastIndex(trimmed, "/"); i >= 0 {
return true // root link return strings.TrimSpace(trimmed[i+1:])
} }
for _, seg := range strings.Split(trimmed, "/") { return trimmed
if seg == "" || seg == "." || seg == ".." { }
return false
} // wikiSearchHref builds the search-page URL for a query. Escaping leaves only
} // URL-safe bytes, so the result needs no further HTML escaping in an href.
return true func wikiSearchHref(query string) string {
return "/?q=" + url.QueryEscape(query)
} }
type wikiLinkParser struct{} type wikiLinkParser struct{}
@@ -75,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
if m == nil { if m == nil {
return nil return nil
} }
target := bytes.TrimSpace(line[m[2]:m[3]]) // A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
if !isValidWikiTarget(target) { // not a link; leave the source text alone.
query := wikiSearchQuery(string(line[m[2]:m[3]]))
if query == "" {
return nil return nil
} }
var display []byte var display []byte
@@ -85,76 +87,12 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
} }
block.Advance(m[1]) block.Advance(m[1])
return &wikiLinkNode{ return &wikiLinkNode{
Target: append([]byte(nil), target...), Query: []byte(query),
Display: append([]byte(nil), display...), Display: append([]byte(nil), display...),
} }
} }
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and type wikiLinkRenderer struct{}
// returns the cleaned absolute path.
func normalizeWikiTarget(target string) string {
if target == "/" {
return "/"
}
return "/" + strings.Trim(target, "/")
}
// wikiTargetHref converts a wiki target to a URL href with each segment
// percent-encoded and a trailing slash appended.
func wikiTargetHref(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
var b strings.Builder
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
b.WriteByte('/')
b.WriteString(url.PathEscape(seg))
}
b.WriteByte('/')
return b.String()
}
// wikiFileHref converts a wiki target to a URL href for the raw file: each
// segment is percent-encoded and, unlike wikiTargetHref, no trailing slash is
// appended. Used for image-embed click-through so the href points straight at
// the file (companion click-interception skips anything ending in "/").
func wikiFileHref(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
var b strings.Builder
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
b.WriteByte('/')
b.WriteString(url.PathEscape(seg))
}
return b.String()
}
// wikiTargetExists reports whether the on-disk path backing the target exists
// under root. Any existing path — file or folder — counts as resolved; only a
// missing path is treated as broken.
func wikiTargetExists(root, target string) bool {
target = normalizeWikiTarget(target)
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
_, err := os.Stat(fsPath)
return err == nil
}
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
func wikiDefaultDisplay(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
segs := strings.Split(strings.TrimPrefix(target, "/"), "/")
return segs[len(segs)-1]
}
type wikiLinkRenderer struct {
root string
}
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiLink, r.render) reg.Register(kindWikiLink, r.render)
@@ -165,38 +103,32 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
n := node.(*wikiLinkNode) n := node.(*wikiLinkNode)
writeWikiLink(w, r.root, string(n.Target), string(n.Display)) writeWikiSearchLink(w, string(n.Query), string(n.Display))
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
// writeWikiLink renders a wiki-link anchor for target with an optional display // writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
// override. An empty display falls back to the target's last segment; a target // search page, labelled with the display override or the query itself. Nothing
// that does not resolve on disk gets the `broken` class. Shared by the wiki-link // is resolved at render time — whether the query matches anything is the search
// renderer and the embed renderer's link fallback. // page's business, so there is no broken state here.
func writeWikiLink(w util.BufWriter, root, target, display string) { func writeWikiSearchLink(w util.BufWriter, query, display string) {
href := wikiTargetHref(target)
if display == "" { if display == "" {
display = wikiDefaultDisplay(target) display = query
} }
broken := !wikiTargetExists(root, target)
w.WriteString(`<a href="`) w.WriteString(`<a href="`)
w.WriteString(href) w.WriteString(wikiSearchHref(query))
w.WriteString(`"`) w.WriteString(`">`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.Write(util.EscapeHTML([]byte(display))) w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`) w.WriteString(`</a>`)
} }
type wikiLinkExt struct{ root string } type wikiLinkExt struct{}
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into // newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
// links resolved against root. // links to the search page. It needs no wiki root — a query is never resolved
func newWikiLinkExt(root string) goldmark.Extender { // against the filesystem.
return &wikiLinkExt{root: root} func newWikiLinkExt() goldmark.Extender {
return &wikiLinkExt{}
} }
func (e *wikiLinkExt) Extend(m goldmark.Markdown) { func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
@@ -206,6 +138,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
util.Prioritized(&wikiLinkParser{}, 199), util.Prioritized(&wikiLinkParser{}, 199),
)) ))
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiLinkRenderer{root: e.root}, 500), util.Prioritized(&wikiLinkRenderer{}, 500),
)) ))
} }