Compare commits

..
56 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
luxick e9061a221c Hide console on windows
The companion does no longer show a terminal window pop up
2026-08-03 19:29:27 +02:00
luxick 5a5305dd42 Update CLAUDE.md 2026-08-03 19:28:03 +02:00
luxick ab57377034 Fix toolbar on mobile. 2026-07-24 16:14:20 +02:00
luxick 65983ec6be Missing changes 2026-07-24 16:10:44 +02:00
luxick 52bc694ffa Allow image embedding 2026-07-24 16:10:29 +02:00
luxick e37dde1e40 preserve scroll position when entering page editor 2026-07-24 15:24:40 +02:00
luxick 3abcc94eca Improve desktop editing experience 2026-07-24 15:16:02 +02:00
luxick 8b9f47f793 Adjust saving button 2026-07-24 12:10:16 +02:00
luxick 85527cfde8 Improve table editing 2026-07-24 11:38:15 +02:00
luxick 5f835a8a4e Omit Files section when no files are present 2026-07-24 11:26:23 +02:00
luxick 1071dbaa09 "Today" link no longer goes directly to editor 2026-07-24 11:21:40 +02:00
luxick da30bb8fc3 Fix back-to-cancel behavior of editor 2026-07-24 10:55:29 +02:00
luxick a849474b26 Remove obsolete parts from overlay 2026-07-23 18:06:12 +02:00
luxick 49c6240416 Remove TODO.txt integration 2026-07-23 17:38:13 +02:00
luxick ddfd29ea0e Prevent page deletion on save 2026-07-21 17:26:31 +02:00
luxick 3f2beafd94 Adjust header spacing 2026-07-21 17:26:15 +02:00
luxick 28d22c040f Do not consider edit mode as a history entry 2026-07-20 17:31:59 +02:00
luxick 34f750beff Exact page matches in search 2026-07-20 09:08:35 +02:00
luxick 5303645173 Improve search result page 2026-07-20 08:54:12 +02:00
luxick d0325fdec5 Center align link favicons 2026-07-05 18:13:22 +02:00
luxick 22059a0b06 Update todo buttons 2026-07-05 18:10:22 +02:00
luxick aa95d04d9e Add todo to the Readme 2026-07-03 12:28:19 +02:00
luxick 08be8c68fe Add quick actions toolbar for todo 2026-07-03 12:24:50 +02:00
luxick a407a2eeaa Hanging indent for todos 2026-07-03 12:11:30 +02:00
luxick 75d6c4d430 Auto refresh todo editor. 2026-07-03 12:02:16 +02:00
luxick 5e72b073b8 todo.txt integration 2026-07-03 11:08:27 +02:00
luxick 8c9b448bdc Render all months in sidebar 2026-07-01 11:00:41 +02:00
luxick ecaf3f4e12 Clean up CSS 2026-07-01 10:09:14 +02:00
luxick 1055c110f4 Rework app-shell layout 2026-07-01 09:59:32 +02:00
luxick 5d069683c4 Enable Hardwraps 2026-06-25 14:23:15 +02:00
luxick 9f6a611dff Video thumbnail generation 2026-06-24 08:36:03 +02:00
luxick fa22244709 Use companion for diary photos 2026-06-22 17:25:11 +02:00
luxick d92c2f008d No hover highlighting on mobile 2026-06-16 18:27:42 +02:00
luxick 7346050b51 Tree styling update
More dense display
2026-06-16 18:05:48 +02:00
luxick 1619240905 Fix missing label 2026-06-16 17:49:34 +02:00
luxick db17f94627 Adjust app layout 2026-06-16 15:24:21 +02:00
luxick 9ed6475775 Folder Sidebar 2026-06-16 15:03:18 +02:00
luxick 7fe0013a5c Sticky Header 2026-06-16 14:00:32 +02:00
luxick f8b09e18bd Remove obsolete functions 2026-06-16 10:25:24 +02:00
luxick 404ce088f3 Allow merging when moving pages 2026-06-16 10:00:41 +02:00
luxick d719b53404 Add weekly averages for weight 2026-06-12 14:10:00 +02:00
63 changed files with 5581 additions and 1445 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
-130
View File
@@ -1,130 +0,0 @@
# CLAUDE.md
## 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).
Do not add new endpoints without a concrete stated need.
## 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
- 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
};
})();
+39 -3
View File
@@ -116,9 +116,16 @@
if (!state.available) return; if (!state.available) return;
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (!e.target.closest) return; if (!e.target.closest) return;
// Match both listing styles: table rows expose the file link inside // Match every listing style: table rows expose the file link inside
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors. // a .list-item row; thumbnail tiles are bare a.thumb-tile anchors;
var anchor = e.target.closest('.list-item a, a.thumb-tile'); // diary photo grids wrap each thumbnail in a .photo-grid anchor.
// The left tree rail's file anchors (a.tree-file) join in too;
// folder anchors there end in "/" and fall through to navigation.
// Search file-result anchors join in too: page results end in "/"
// and fall through to navigation, file results don't and open locally.
// Embedded images (a.embed-link inside rendered content) wrap the
// raw file href, so they open the full-size original locally too.
var anchor = e.target.closest('.list-item a, a.thumb-tile, .photo-grid a, aside.tree-sidebar a.tree-file, .search-card a, .content a.embed-link');
if (!anchor) return; if (!anchor) return;
var item = anchor.closest('.list-item'); var item = anchor.closest('.list-item');
// Only intercept the primary file link, and only for files (not folders). // Only intercept the primary file link, and only for files (not folders).
@@ -150,6 +157,22 @@
}); });
} }
function wireFileRevealButtons() {
// Per-result "open location" buttons on the search page. Unlike the
// page reveal button (which reveals window.location), each carries its
// own file path so it reveals that specific file in its folder.
if (!state.available) return;
var btns = document.querySelectorAll('[data-companion-file-reveal]');
btns.forEach(function (btn) {
btn.hidden = false;
btn.addEventListener('click', function () {
var rel = btn.getAttribute('data-companion-file-reveal');
if (!rel) return;
companionGET('/open-folder', { path: rel }).catch(function () { });
});
});
}
function probeStatus() { function probeStatus() {
return companionGET('/status').then(function (r) { return companionGET('/status').then(function (r) {
if (!r.ok) throw new Error('status ' + r.status); if (!r.ok) throw new Error('status ' + r.status);
@@ -163,11 +186,24 @@
}); });
} }
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();
wireRevealButton(); wireRevealButton();
wireFileRevealButtons();
}); });
} }
+8 -13
View File
@@ -1,29 +1,24 @@
<div class="diary-cal panel panel-sidebar" <div class="diary-cal panel panel-sidebar"
data-display-year="{{.DisplayYear}}" data-display-year="{{.DisplayYear}}"
data-display-month="{{.DisplayMonth}}"> data-display-month="{{.DisplayMonth}}">
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div> <div class="panel-header row">
<div class="diary-cal-nav"> <a href="{{.DiaryURL}}">Chronological</a>
<div class="dropdown diary-cal-drop"> <div class="dropdown diary-cal-drop">
<button type="button" class="btn" data-cal-month-link data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"> {{.DisplayMonthName}} </button> <button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">{{.DisplayYear}} ▾</button>
<div class="dropdown-menu scrollable">
{{range .Months}}<a class="btn btn-block" data-cal-month-jump="{{.Num}}" href="{{.AnchorURL}}">{{.Name}}</a>{{end}}
</div>
</div>
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
<div class="dropdown diary-cal-drop">
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
<div class="dropdown-menu align-right scrollable"> <div class="dropdown-menu align-right scrollable">
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}} {{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
</div> </div>
</div> </div>
</div> </div>
{{range .Months}} {{range $i, $m := .Months}}
<table class="diary-cal-grid" data-cal-month="{{.Num}}"{{if ne .Num $.DisplayMonth}} hidden{{end}}> {{if $i}}<hr/>{{end}}
<div class="diary-cal-month"><a href="{{$m.AnchorURL}}">{{$m.Name}}</a></div>
<table class="diary-cal-grid" data-cal-month="{{$m.Num}}">
<thead> <thead>
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr> <tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
</thead> </thead>
<tbody> <tbody>
{{range .Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr> {{range $m.Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
+30 -34
View File
@@ -3,47 +3,43 @@
if (!cal) return; if (!cal) return;
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown); cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
var displayYear = parseInt(cal.dataset.displayYear, 10); var displayMonth = parseInt(cal.dataset.displayMonth, 10);
var current = parseInt(cal.dataset.displayMonth, 10);
var monthLabel = cal.querySelector("[data-cal-month-link]");
var months = {}; var months = {};
cal.querySelectorAll("[data-cal-month]").forEach(function (t) { cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
months[parseInt(t.dataset.calMonth, 10)] = t; months[parseInt(t.dataset.calMonth, 10)] = t;
}); });
var jumpLinks = {};
cal.querySelectorAll("[data-cal-month-jump]").forEach(function (a) {
jumpLinks[parseInt(a.dataset.calMonthJump, 10)] = a;
});
function show(m) { // Centering only applies inside the persistent desktop rail (.sidebar). On
if (m === current) return; // mobile the widget is re-parented into .overlay-body, where we leave the
if (!months[m]) return; // grids stacked from January and do not scroll (design decision).
months[current].hidden = true; function railContainer() {
months[m].hidden = false; var el = cal.parentElement;
current = m; while (el) {
if (monthLabel) { if (el.classList) {
var label = jumpLinks[m]; if (el.classList.contains("overlay-body")) return null;
if (label) monthLabel.textContent = " " + label.textContent + " "; if (el.classList.contains("sidebar")) return el;
}
el = el.parentElement;
} }
return null;
} }
// Dropdown month picks scroll via the href anchor; we also swap the grid // Scroll only the rail so the given month grid sits in its vertical center;
// so the calendar reflects what the user just navigated to. // the main document/window position is untouched (priority: don't disturb
Object.keys(jumpLinks).forEach(function (key) { // the reading position).
var a = jumpLinks[key]; function centerMonth(m) {
a.addEventListener("click", function () { show(parseInt(key, 10)); }); var grid = months[m];
}); if (!grid) return;
var container = railContainer();
// Any in-page anchor click (#YYYY-MM or #YYYY-MM-DD) updates the calendar if (!container) return;
// so it tracks the user's focus through the year page. var offset = grid.getBoundingClientRect().top -
function syncFromHash() { container.getBoundingClientRect().top + container.scrollTop;
var h = window.location.hash; container.scrollTop = offset - (container.clientHeight - grid.clientHeight) / 2;
if (!h) return;
var m = h.match(/^#(\d{4})-(\d{2})(?:-\d{2})?$/);
if (!m) return;
if (parseInt(m[1], 10) !== displayYear) return;
show(parseInt(m[2], 10));
} }
window.addEventListener("hashchange", syncFromHash);
syncFromHash(); // Center the current month once, on initial render. Anchor navigation
// (#YYYY-MM / #YYYY-MM-DD) deliberately does NOT re-center the rail —
// re-scrolling on every hop is disorienting; the today/current cell
// highlighting is enough to keep bearings.
centerMonth(displayMonth);
})(); })();
+2 -2
View File
@@ -1,12 +1,12 @@
{{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">
{{range .Photos}} {{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a> <a href="{{.URL}}"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
+4 -2
View File
@@ -1,6 +1,6 @@
{{define "headerActions"}} {{define "headerActions"}}
<a class="btn" href="{{.PostURL}}">CANCEL</a> <a class="btn" href="{{.PostURL}}">CANCEL</a>
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button> <button class="btn" type="button" data-action="save" data-key="S" title="Save (S)">SAVE</button>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@@ -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">
+167 -37
View File
@@ -23,13 +23,38 @@
}; };
} }
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
// The table keymap sits at Prec.highest, so without this guard it would
// swallow the key before the completion keymap ever sees it. Detect the popup
// via its tooltip element rather than exporting completionStatus from the
// vendored bundle (which would force a bundle rebuild).
function completionOpen() {
return !!document.querySelector('.cm-tooltip-autocomplete');
}
function tableNavKey(fn) {
var handler = tableKey(fn);
return function (view) {
if (completionOpen()) return false;
return handler(view);
};
}
var tableKeymap = [ var tableKeymap = [
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) }, { key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) }, { key: 'Shift-Delete', run: tableKey(T.deleteRow) },
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
{ key: 'Tab', run: tableNavKey(T.nextCell) },
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
]; ];
var state = CM.EditorState.create({ var state = CM.EditorState.create({
doc: hidden.value, doc: hidden.value,
// Start with the caret at the end of the existing content — editing an
// existing page almost always means appending, and the default (start
// of doc) buries the caret behind the leading heading.
selection: { anchor: hidden.value.length },
extensions: [ extensions: [
CM.history(), CM.history(),
CM.drawSelection(), CM.drawSelection(),
@@ -140,7 +165,46 @@
function syncContent() { function syncContent() {
hidden.value = view.state.doc.toString(); hidden.value = view.state.doc.toString();
} }
form.addEventListener('submit', syncContent);
// Save POSTs via fetch and then rewrites the *editor's* history entry with
// the resulting page, so the edit session and its result share a single
// entry (see history-nav.js). A plain form submit would push a second one
// and leave the editor sitting in history behind the saved page.
//
// The server answers 204 + X-Target instead of a 303 because the target may
// carry a #section anchor the client cannot compute, and fetch drops the
// fragment from a followed redirect.
function postSave() {
syncContent();
var body = new URLSearchParams(new FormData(form)).toString();
fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Save-Mode': 'replace',
},
body: body,
}).then(function (res) {
if (!res.ok) {
return res.text().then(function (msg) {
alert(msg || ('Save failed (' + res.status + ')'));
});
}
var target = res.headers.get('X-Target') || form.action;
// replaceState + reload rather than location.replace: if target
// differs from the current URL only by fragment the browser would
// skip the re-fetch and show pre-save content.
window.history.replaceState(null, '', target);
window.location.reload();
}).catch(function () {
alert('Network error — the page was not saved');
});
}
form.addEventListener('submit', function (e) {
e.preventDefault();
postSave();
});
// --- Actions --- // --- Actions ---
@@ -159,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('- [ ] '); },
@@ -176,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({
@@ -220,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
));
}
}
});
} }
}); });
} }
+86
View File
@@ -262,8 +262,94 @@ window.EditorTables = (function () {
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length)); return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
} }
// Absolute cursor offset at the start of the `col`-th cell's content on
// `lineIdx` of a *formatted* table (i.e. just past the "| " / " | " that
// opens the cell). Clamps to the last cell if col overflows the row.
function cellCursor(text, lineIdx, col) {
var lines = text.split('\n');
var offset = 0;
for (var i = 0; i < lineIdx; i++) offset += lines[i].length + 1;
var line = lines[lineIdx] || '';
var pipes = [];
for (var c = 0; c < line.length; c++) if (line.charAt(c) === '|') pipes.push(c);
if (pipes.length === 0) return Math.min(offset, text.length);
var p = pipes[Math.min(col, pipes.length - 1)];
return Math.min(offset + p + 2, text.length);
}
// Shared engine for the Enter / Tab / Shift-Tab cell navigation. Reformats
// the table and drops the cursor into the target cell, appending an empty
// row when navigation runs past the last row. Returns null when the cursor
// is not in a table (so the caller can fall back to the editor default).
function moveInTable(text, cursorPos, mode) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var colIdx = getCursorColumn(text, cursorPos);
if (colIdx === null) return null;
var sepRel = -1, colCount = 0;
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
if (cells.length > colCount) colCount = cells.length;
if (sepRel === -1 && isSeparatorRow(cells)) sepRel = i - range.start;
}
if (sepRel === -1) return null;
var lastRel = range.end - range.start;
var rowRel = range.cursorLine - range.start;
var targetRow = rowRel, targetCol = colIdx;
if (mode === 'enter') {
targetRow = rowRel + 1;
if (targetRow === sepRel) targetRow++;
} else if (mode === 'tab') {
targetCol = colIdx + 1;
if (targetCol >= colCount) {
targetCol = 0;
targetRow = rowRel + 1;
if (targetRow === sepRel) targetRow++;
}
} else if (mode === 'shifttab') {
targetCol = colIdx - 1;
if (targetCol < 0) {
targetCol = colCount - 1;
targetRow = rowRel - 1;
if (targetRow === sepRel) targetRow--;
}
if (targetRow < 0) return null;
}
var lines = range.lines.slice();
if (targetRow > lastRel) {
var emptyCells = [];
for (var c = 0; c < colCount; c++) emptyCells.push('');
var emptyLine = '| ' + emptyCells.join(' | ') + ' |';
while (lastRel < targetRow) {
lines.splice(range.start + lastRel + 1, 0, emptyLine);
lastRel++;
}
}
var tableStartOffset = 0;
for (var i = 0; i < range.start; i++) tableStartOffset += lines[i].length + 1;
var formatted = formatTableText(lines.join('\n'), tableStartOffset);
if (!formatted) return null;
return {
text: formatted.text,
cursor: cellCursor(formatted.text, range.start + targetRow, targetCol),
};
}
function nextRowSameColumn(text, cursorPos) { return moveInTable(text, cursorPos, 'enter'); }
function nextCell(text, cursorPos) { return moveInTable(text, cursorPos, 'tab'); }
function prevCell(text, cursorPos) { return moveInTable(text, cursorPos, 'shifttab'); }
return { return {
formatTableText: formatTableText, formatTableText: formatTableText,
nextRowSameColumn: nextRowSameColumn,
nextCell: nextCell,
prevCell: prevCell,
setColumnAlignment: setColumnAlignment, setColumnAlignment: setColumnAlignment,
insertColumn: insertColumn, insertColumn: insertColumn,
deleteColumn: deleteColumn, deleteColumn: deleteColumn,
+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);
}); });
+13
View File
@@ -4,6 +4,8 @@
switch (e.key) { switch (e.key) {
case 'E': case 'E':
e.preventDefault(); e.preventDefault();
// assign, not replace — opening the editor pushes a history
// entry so Back cancels the edit (see history-nav.js).
window.location.href = window.location.pathname + '?edit'; window.location.href = window.location.pathname + '?edit';
break; break;
case 'N': case 'N':
@@ -54,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);
});
});
+37
View File
@@ -0,0 +1,37 @@
// Keeps the editor from lingering in history once you leave it — while still
// letting the browser/Android Back button CANCEL an edit session.
//
// Opening the editor pushes a normal history entry, so Back exits the editor
// and returns to the page (the primary "back to cancel" gesture on mobile).
//
// Leaving the editor by CANCEL is the one transition we rewrite: the CANCEL
// link points back at the same page, so we replace the editor entry instead of
// pushing a second page entry on top of it. Without this, page -> edit -> CANCEL
// would leave [page, editor, page] and Back would walk straight back into the
// editor. SAVE does the equivalent from editor/main.js (replaceState + reload).
(function () {
function isEdit(loc) {
return new URLSearchParams(loc.search).has('edit');
}
document.addEventListener('click', function (e) {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target.closest ? e.target.closest('a[href]') : null;
if (!a || a.target || a.hasAttribute('download')) return;
// Only act while inside the editor. Entering the editor stays a normal
// push so Back can cancel it.
if (!isEdit(window.location)) return;
var url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
if (url.pathname !== window.location.pathname) return;
// Leaving the editor to another page (e.g. a wikilink) keeps its normal
// push; only the same-page exit (CANCEL) is collapsed.
if (isEdit(url)) return;
e.preventDefault();
window.location.replace(url.href);
});
}());
+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

+35 -17
View File
@@ -10,38 +10,56 @@
<link rel="stylesheet" href="/_/style.css" /> <link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script> <script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script> <script src="/_/global-shortcuts.js"></script>
<script src="/_/history-nav.js"></script>
<script src="/_/scroll-sync.js" defer></script>
<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 .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}} {{block "headScripts" .}}{{end}}
</head> </head>
<body> <body>
<header> <header>
<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></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>
{{if .ParentURL}}<a class="nav-up" href="{{.ParentURL}}" tabindex="-1" title="Up" aria-label="Up">Up <svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square"><path d="M8 13V3M3 8l5-5 5 5"/></svg></a>{{end}}
</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="page-wrap"> <div class="shell">
<main> {{if .ShowPageChrome}}<aside class="tree-sidebar col">
{{block "content" .}}{{end}} <div class="tree-scroll"></div>
</main> </aside>{{end}}
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside> <div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
</div> </div>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
{{block "extras" .}}{{end}} {{block "extras" .}}{{end}}
</body> </body>
</html> </html>
+96
View File
@@ -0,0 +1,96 @@
// Full-viewport Overlay component (mobile). Distinct from the .modal dialog:
// this hosts a whole sidebar at full screen with its own scroll and a top-right close control.
// It reuses the low-level focus/Escape/bfcache patterns from modal.js rather than that component's panel chrome.
//
// openOverlay(node) MOVES `node` (an existing rail <aside>) into the overlay
// body and restores it to its original DOM position on close, so the rail keeps
// its state (expanded tree folders, rendered TOC). Only one overlay is open at
// a time.
(function () {
var overlay = null;
var bodyEl = null;
var closeBtn = null;
var hosted = null; // the moved node
var placeholder = null; // marks where to restore it
var prevFocus = null;
var onKeydown = null;
function build() {
overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
var bar = document.createElement('div');
bar.className = 'overlay-bar';
closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn overlay-close';
closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', close);
bar.appendChild(closeBtn);
bodyEl = document.createElement('div');
bodyEl.className = 'overlay-body';
bodyEl.addEventListener('click', function (e) {
if (e.target.closest('a')) close();
});
overlay.appendChild(bar);
overlay.appendChild(bodyEl);
}
function open(node) {
if (overlay && overlay.parentNode) close();
if (!overlay) build();
prevFocus = document.activeElement;
hosted = node;
placeholder = document.createComment('overlay-slot');
node.parentNode.insertBefore(placeholder, node);
bodyEl.appendChild(node);
document.body.appendChild(overlay);
overlay.classList.add('is-open');
onKeydown = function (e) {
if (e.key === 'Escape') { e.preventDefault(); close(); }
};
document.addEventListener('keydown', onKeydown);
setTimeout(function () { closeBtn.focus(); }, 0);
}
function close() {
if (!overlay || !overlay.parentNode) return;
if (onKeydown) {
document.removeEventListener('keydown', onKeydown);
onKeydown = null;
}
// Restore the hosted rail to its original spot in the shell.
if (hosted && placeholder && placeholder.parentNode) {
placeholder.parentNode.insertBefore(hosted, placeholder);
placeholder.parentNode.removeChild(placeholder);
}
hosted = null;
placeholder = null;
overlay.classList.remove('is-open');
overlay.parentNode.removeChild(overlay);
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring
// the rail) if the page is being hidden while it is still open.
window.addEventListener('pagehide', close);
window.openOverlay = open;
window.closeOverlay = close;
})();
+40 -19
View File
@@ -13,6 +13,11 @@ function encodePickedPath(p) {
// re-fetching, so a server-side mutation wouldn't be reflected. Instead, // re-fetching, so a server-side mutation wouldn't be reflected. Instead,
// rewrite the current entry's URL via history.replaceState, then reload — the // rewrite the current entry's URL via history.replaceState, then reload — the
// reload always re-fetches and preserves the (new) URL including its fragment. // reload always re-fetches and preserves the (new) URL including its fragment.
function navigateReplace(target) {
window.history.replaceState(null, '', target);
window.location.reload();
}
function postReplace(action, body, target) { function postReplace(action, body, target) {
var init = { method: 'POST', redirect: 'manual' }; var init = { method: 'POST', redirect: 'manual' };
if (body) { if (body) {
@@ -21,8 +26,7 @@ function postReplace(action, body, target) {
} }
fetch(action, init).then(function (res) { fetch(action, init).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) { if (res.type === 'opaqueredirect' || res.ok) {
window.history.replaceState(null, '', target); navigateReplace(target);
window.location.reload();
return; return;
} }
return res.text().then(function (msg) { return res.text().then(function (msg) {
@@ -71,6 +75,38 @@ function newPage() {
}); });
} }
// submitMove POSTs a move and navigates on success. When the server reports
// the destination folder already exists but can be merged (the source carries
// a page and the destination has none), it asks the user to confirm and
// retries the same move with &merge=1.
function submitMove(action, target) {
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
openModal({
title: 'Merge folders?',
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
confirm: {
label: 'MERGE',
onConfirm: function () {
closeModal();
submitMove(action + '&merge=1', target);
}
}
});
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function movePage() { function movePage() {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, ''); var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
if (!current) return; if (!current) return;
@@ -93,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 () {
@@ -118,10 +140,9 @@ 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();
postReplace(action, null, target); submitMove(action, target);
} }
} }
}); });
-114
View File
@@ -1,114 +0,0 @@
(function () {
var content = document.querySelector('.content');
if (!content) return;
var allHeadings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (!allHeadings.length) return;
function copyAnchor(id, item, label, menu) {
var url = window.location.origin + window.location.pathname + '#' + id;
function flash() {
item.textContent = 'Copied!';
setTimeout(function () {
item.textContent = label;
menu.classList.remove('is-open');
}, 1200);
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = url;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
if (ok) flash();
else openModal({
title: 'Copy anchor link',
body: 'Could not copy automatically. URL:\n' + url,
confirm: { label: 'OK', onConfirm: function () { closeModal(); } }
});
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(flash, fallback);
} else {
fallback();
}
}
function addTask(sectionIndex, headingId) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Task description';
var ctrl = openModal({
title: 'Add task',
body: input,
confirm: {
label: 'ADD',
initiallyDisabled: true,
onConfirm: function () {
var text = input.value.trim();
if (!text) return;
var action = window.location.pathname + '?addtask=' + sectionIndex;
var target = window.location.pathname + '#' + headingId;
closeModal();
postReplace(action, 'text=' + encodeURIComponent(text), target);
}
}
});
input.addEventListener('input', function () {
ctrl.setConfirmDisabled(input.value.trim() === '');
});
}
allHeadings.forEach(function (h, i) {
if (!h.id) return;
var tag = h.tagName.toLowerCase();
if (tag !== 'h2' && tag !== 'h3' && tag !== 'h4') return;
var sectionIndex = i + 1;
var wrap = document.createElement('span');
wrap.className = 'dropdown heading-anchor';
var trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'dropdown-toggle';
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-label', 'Section actions');
trigger.textContent = '#';
var menu = document.createElement('div');
menu.className = 'dropdown-menu';
var copyLabel = 'Copy anchor link';
var copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'btn btn-tool btn-block';
copyBtn.dataset.action = 'copy-anchor';
copyBtn.textContent = copyLabel;
copyBtn.addEventListener('click', function (e) {
e.stopPropagation();
copyAnchor(h.id, copyBtn, copyLabel, menu);
});
var addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-tool btn-block';
addBtn.dataset.action = 'add-task';
addBtn.textContent = 'Add task';
addBtn.addEventListener('click', function () { addTask(sectionIndex, h.id); });
menu.appendChild(copyBtn);
menu.appendChild(addBtn);
wrap.appendChild(trigger);
wrap.appendChild(menu);
h.insertBefore(wrap, h.firstChild);
wireDropdown(trigger);
});
}());
+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);
}
}
});
}
+1 -1
View File
@@ -5,7 +5,7 @@
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico'; img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
img.width = 16; img.width = 16;
img.height = 16; img.height = 16;
img.style.verticalAlign = 'middle'; img.style.verticalAlign = 'center';
img.style.marginRight = '3px'; img.style.marginRight = '3px';
a.prepend(img); a.prepend(img);
}); });
+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);
});
+26 -17
View File
@@ -1,6 +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}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
{{end}}
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<button class="btn btn-block" onclick="newCanvas()" title="New canvas">NEW CANVAS</button>
{{end}}{{end}}
{{define "content"}} {{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}}
@@ -13,7 +35,7 @@
<div class="thumb-grid"> <div class="thumb-grid">
{{range .Entries}} {{range .Entries}}
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}"> <a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
{{if .ThumbURL}}<img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}} {{if .ThumbURL}}<span class="thumb-media"><img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{if .IsVideo}}<span class="thumb-play" aria-hidden="true">▶</span>{{end}}</span>{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
<span class="thumb-label truncate">{{.Name}}</span> <span class="thumb-label truncate">{{.Name}}</span>
</a> </a>
{{end}} {{end}}
@@ -39,26 +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 .CanEdit}}<nav class="actions panel panel-sidebar"> {{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
<div class="panel-header">ACTIONS</div>
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
{{end}}
<button class="btn btn-block" data-action="clean-tasks" onclick="cleanUpTasks()" title="Clean up finished tasks" hidden>CLEAN UP TASKS</button>
{{if not .IsRoot}}
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
{{end}}
</nav>{{end}}{{if .SidebarWidget}}{{.SidebarWidget}}{{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);
-23
View File
@@ -1,23 +0,0 @@
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.title = "Menu";
fab.setAttribute("aria-label", "Menu");
fab.setAttribute("aria-expanded", "false");
fab.textContent = "≡";
fab.addEventListener("click", function () {
var open = aside.classList.toggle("is-open");
fab.setAttribute("aria-expanded", open ? "true" : "false");
});
aside.addEventListener("click", function (e) {
if (e.target.tagName === "A") {
aside.classList.remove("is-open");
fab.setAttribute("aria-expanded", "false");
}
});
document.body.appendChild(fab);
});
-21
View File
@@ -21,25 +21,4 @@
}); });
}); });
}); });
var hasChecked = !!document.querySelector('input.task-checkbox:checked');
if (hasChecked) {
var btn = document.querySelector('[data-action="clean-tasks"]');
if (btn) btn.hidden = false;
}
})(); })();
function cleanUpTasks() {
openModal({
title: 'Clean up tasks',
body: 'Remove all completed tasks from this page?',
confirm: {
label: 'CLEAN UP',
danger: true,
onConfirm: function () {
closeModal();
postReplace(window.location.pathname + '?cleantasks=1', null, window.location.pathname);
}
}
});
}
+3 -4
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,13 +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);
var rail = document.querySelector("aside.sidebar"); content.insertBefore(nav, content.firstChild);
rail.appendChild(nav);
}); });
+61
View File
@@ -0,0 +1,61 @@
// Roughly preserve reading position when entering the editor.
//
// While reading a page we record how far the center column is scrolled (as a
// fraction of its scrollable height, keyed by path). When the same path opens
// in the editor we restore that fraction, so you land near where you were
// reading instead of at the top. It is fraction-based — the rendered page and
// the raw-markdown editor have different heights — so the match is deliberately
// approximate. This only moves the viewport; the caret default (end of doc) is
// unchanged, so a click still decides where you actually edit.
(function () {
var center = document.querySelector('.center');
if (!center) return;
var key = 'scrollsync:' + location.pathname;
// Only restore a freshly-recorded position: a stale entry from earlier in
// the tab session would jump a direct ?edit open to the wrong place.
var MAX_AGE_MS = 5 * 60 * 1000;
var params = new URLSearchParams(location.search);
var inEditor = params.has('edit');
// Section / insert edits load only a slice of the document into the editor,
// so a whole-page reading fraction does not map onto them — restore only for
// a full-page ?edit.
var fullPageEdit = inEditor && !params.has('section') && !params.has('insert_before');
function maxScroll() { return Math.max(0, center.scrollHeight - center.clientHeight); }
if (inEditor) {
if (!fullPageEdit) return;
var raw = sessionStorage.getItem(key);
if (!raw) return;
var data;
try { data = JSON.parse(raw); } catch (e) { return; }
if (!data || typeof data.f !== 'number' || Date.now() - (data.t || 0) > MAX_AGE_MS) return;
// Wait for CodeMirror to mount and the column to reach full height, then
// map the fraction onto it. A double rAF after load lets CM's initial
// layout and focus scroll settle so ours wins.
window.addEventListener('load', function () {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
center.scrollTop = data.f * maxScroll();
});
});
});
return;
}
// Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens.
var pending = false;
center.addEventListener('scroll', function () {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
var m = maxScroll();
try {
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {}
});
}, { passive: true });
})();
+49 -1
View File
@@ -11,8 +11,56 @@ function rebuildIndex() {
}); });
} }
function encodeSearchPath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
}
// createSearchPage runs the new-page flow (pick a parent folder, then confirm
// the name) starting from the search query, so a query with no exact page can
// be turned into that page in one step. The name field is pre-filled with the
// query but stays editable.
function createSearchPage(name) {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
openTreePicker({
title: 'New page — where?',
mode: 'folder',
initialPath: current,
preselect: current,
hideFiles: true,
confirmLabel: 'NEXT',
onSelect: function (parentPath) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Page name';
input.value = name || '';
openModal({
title: 'New page — name?',
body: input,
confirm: {
label: 'CREATE',
onConfirm: function () {
var finalName = input.value.trim();
if (!finalName) return;
var base = parentPath === '/' ? '/' : encodeSearchPath(parentPath) + '/';
window.location.href = base + encodeURIComponent(finalName) + '/?edit';
}
}
});
}
});
}
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
createLink.addEventListener('click', function (e) {
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
}
// Focus the search input on results pages so Tab steps directly into the // Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM // first match — the input sits immediately before the results in DOM
+30 -14
View File
@@ -4,32 +4,48 @@
{{define "content"}} {{define "content"}}
{{if .Query}} {{if .Query}}
{{if .Results}} {{if .Exact}}
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for &ldquo;{{.Query}}&rdquo;</p> <h2 class="search-section">Exact Match</h2>
<hr/> {{range .Exact}}
{{range .Results}}
<article class="search-card"> <article class="search-card">
<a href="{{.URL}}">{{.Name}}</a> <a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div> <div class="muted">/{{.Path}}</div>
</article> </article>
{{end}} {{end}}
{{else}} {{else}}
<p class="empty">No matches for &ldquo;{{.Query}}&rdquo;.</p> <p class="muted">No page named &ldquo;{{.Query}}&rdquo; — <button class="btn btn-small" data-create-page="{{.Query}}">create it</button></p>
{{end}}
{{if .Pages}}
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
{{range .Pages}}
<article class="search-card">
<a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div>
</article>
{{end}}
{{end}}
{{if .Files}}
<h2 class="search-section">Matching Files <span class="muted">{{.FileTotal}}</span></h2>
{{range .Files}}
<article class="search-card">
<div class="row">
{{fileIcon .Name}}
<a href="{{.URL}}">{{.Name}}</a>
<button class="btn btn-small" data-companion-file-reveal="{{.Path}}" hidden title="Open location in file manager">open</button>
</div>
<div class="muted">/{{.Path}} · {{.Meta}}</div>
</article>
{{end}}
{{end}} {{end}}
{{else}} {{else}}
<p class="empty">Enter a query above.</p> <p class="empty">Enter a query above.</p>
{{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}}
+615 -155
View File
@@ -24,9 +24,22 @@
--link-hover: #d6d24d; --link-hover: #d6d24d;
--danger: #c40141; --danger: #c40141;
--danger-hover: #d03467; --danger-hover: #d03467;
--overlay: rgba(0, 0, 0, 0.55);
/* JSON Canvas preset colours "1".."6" (red, orange, yellow, green, cyan,
purple), pulled toward the app palette so a canvas does not look like a
different program. Hex colours in a .canvas file pass through untouched. */
--canvas-1: #e05555;
--canvas-2: #e08a3c;
--canvas-3: #d9b03c;
--canvas-4: #5aab64;
--canvas-5: #01b6c4;
--canvas-6: #a069c4;
--canvas-edge: #9a9a9a;
--border: 1px solid var(--secondary); --border: 1px solid var(--secondary);
--border-dashed: 1px dashed var(--secondary); --border-dashed: 1px dashed var(--secondary);
--border-guide: 1px solid var(--bg-panel-hover);
--space-1: 0.25rem; --space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
@@ -36,28 +49,43 @@
--font-xs: 0.75rem; --font-xs: 0.75rem;
--font-sm: 0.85rem; --font-sm: 0.85rem;
/* Width of the persistent left folder-tree rail (desktop). */
--tree-width: 15rem;
/* Comfortable max reading width for the center column's content + footer.
The shell itself is edge-to-edge; this keeps prose lines readable on
wide monitors (content stays centered within the center column). */
--reading-width: 60rem;
} }
/* Anchor / TOC jumps are handled by the .center scroll container's own
scroll-padding-top (the header no longer overlaps content in the app-shell),
so no html-level scroll padding is needed. */
/* === Base === */ /* === Base === */
/* App-shell: the body is a full-viewport, non-scrolling grid of two rows —
the fixed-height header and a 1fr shell that owns all scrolling internally.
100dvh (not 100vh) so the mobile browser's dynamic toolbar doesn't clip the
bottom row; overflow:hidden pins the header and lets each shell column scroll
on its own. */
body { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; height: 100dvh;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: auto; overflow: hidden;
font: 1rem "Iosevka Etoile", monospace; font: 1rem "Iosevka Etoile", monospace;
display: flex; display: grid;
flex-direction: column; grid-template-rows: auto 1fr;
} }
* { 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; }
@@ -73,37 +101,63 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
.space-between { justify-content: space-between; } .space-between { justify-content: space-between; }
.divider-dashed { border-bottom: var(--border-dashed); } .divider-dashed { border-bottom: var(--border-dashed); }
/* === Page layout === /* === Page layout (app-shell) ===
Note: sticky positioning on .sidebar depends on no ancestor having The shell is a full-height flex row below the header: left rail | center |
overflow: auto/hidden. If you add scroll containment above this, sticky right rail. Each column owns its own vertical scroll (overflow-y:auto +
will silently break. */ min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no
.page-wrap { centered max-width container. The right rail collapses out of the flex flow
display: grid; when empty (:empty { display:none }), letting the center reclaim the width. */
grid-template-columns: minmax(0, 1fr) 14rem; .shell {
gap: var(--space-5); display: flex;
max-width: 1280px; min-height: 0;
margin: 0 auto; overflow: hidden;
padding: 0 var(--space-4); }
/* Center column: main content plus the footer beneath it, scrolling together.
Content and footer are held to a comfortable reading width and centered
within the (edge-to-edge) column. */
.center {
flex: 1 1 auto;
min-width: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
scroll-padding-top: var(--space-4);
}
main {
width: 100%; width: 100%;
flex: 1; max-width: var(--reading-width);
align-items: start; padding: var(--space-5) var(--space-4);
flex: 1 0 auto;
min-width: 0;
} }
main { padding: var(--space-5) var(--space-4); width: 100%; flex: 1; min-width: 0; }
/* === Header / footer === /* === Header / footer ===
Three-column grid (breadcrumbs left, search centre, actions right) so the Header is the app-shell's fixed top row (grid row 1 of <body>): full width,
centre stays reserved even when search is hidden in editor mode. Mobile never scrolls. Three-column grid (breadcrumbs left, search centre, actions
(≤1100px) collapses to a two-row layout — see responsive block below. */ right) so the centre stays reserved even when search is hidden in editor
mode; the far-right actions column hosts the [ACTIONS] dropdown. position +
z-index keep the header (and its open dropdown) painting above the shell.
Mobile (≤1100px) collapses to a compact three-column layout — see responsive
block below. */
header { header {
position: relative;
z-index: 40;
background: var(--bg);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-bottom: var(--border-dashed); border-bottom: var(--border-dashed);
display: grid; display: grid;
grid-template-columns: 1fr minmax(0, auto) 1fr; grid-template-columns: 1fr minmax(0, 50rem) 1fr;
grid-template-areas: "crumbs search actions"; grid-template-areas: "crumbs search actions";
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
} }
/* Footer rides at the end of the center column's content (scrolls with it, not
pinned to the viewport). Held to the same reading width as main so its top
border delimits the content, not the whole shell. */
footer { footer {
width: 100%;
max-width: var(--reading-width);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-top: var(--border-dashed); border-top: var(--border-dashed);
display: flex; display: flex;
@@ -114,13 +168,6 @@ footer {
.breadcrumb { grid-area: crumbs; gap: var(--space-1); min-width: 0; } .breadcrumb { grid-area: crumbs; gap: var(--space-1); min-width: 0; }
.header-actions { grid-area: actions; justify-content: flex-end; flex-wrap: wrap; } .header-actions { grid-area: actions; justify-content: flex-end; flex-wrap: wrap; }
.logo { width: 1.1em; height: 1.1em; vertical-align: center; } .logo { width: 1.1em; height: 1.1em; vertical-align: center; }
.nav-up {
display: inline-flex;
align-items: center;
color: var(--secondary);
padding: 0 var(--space-1);
}
.nav-up:hover { color: var(--primary-hover); }
/* === Panel === /* === Panel ===
Bordered container recipe shared by listings, sidebar widgets, the tree Bordered container recipe shared by listings, sidebar widgets, the tree
@@ -168,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;
@@ -178,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 ===
@@ -227,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 {
@@ -235,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); }
@@ -267,17 +307,41 @@ main > h2 {
text-decoration: line-through; text-decoration: line-through;
} }
.dropdown.heading-anchor { margin-right: 0.4em; font-weight: normal; } /* === Embedded images ===
.heading-anchor .dropdown-toggle { Wiki-style figure box: a bounded, framed thumbnail with an optional caption.
background: none; The fixed width matches the 300px thumbnail request so the box never scales
border: none; the image up. left/right float so body text wraps; center is a plain block. */
padding: 0; .embed {
cursor: pointer; margin: var(--space-2) 0;
width: 300px;
max-width: 100%;
padding: var(--space-2);
background: var(--bg-panel);
border: var(--border);
}
.embed-left {
float: left;
margin-right: var(--space-4);
}
.embed-right {
float: right;
margin-left: var(--space-4);
}
.embed-center {
margin-inline: auto;
}
.embed .embed-link { display: block; }
.embed img {
display: block;
width: 100%;
height: auto;
}
.embed figcaption {
margin-top: var(--space-2);
color: var(--text-muted); color: var(--text-muted);
font: inherit; font-size: var(--font-sm);
font-weight: normal; line-height: 1.4;
} }
.heading-anchor .dropdown-toggle: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
@@ -287,8 +351,13 @@ main > h2 {
.data-table th, .data-table th,
.data-table td { padding: 0.4rem var(--space-3); text-align: left; } .data-table td { padding: 0.4rem var(--space-3); text-align: left; }
.data-table:not(.data-table-grid) tbody tr + tr { border-top: var(--border); } .data-table:not(.data-table-grid) tbody tr + tr { border-top: var(--border); }
.data-table:not(.data-table-grid) tbody tr:hover,
.data-table tr.is-active { background: var(--bg-panel-hover); } .data-table tr.is-active { background: var(--bg-panel-hover); }
/* Hover-highlight only on devices with a real pointer. On touch, a tap or
drag-scroll leaves an emulated :hover stuck on the last-touched row until
the next tap — distracting while scrolling a listing. */
@media (hover: hover) {
.data-table:not(.data-table-grid) tbody tr:hover { background: var(--bg-panel-hover); }
}
.data-table tbody tr.is-empty, .data-table tbody tr.is-empty,
.data-table tbody tr.is-empty:hover { .data-table tbody tr.is-empty:hover {
color: var(--text-muted); color: var(--text-muted);
@@ -330,6 +399,8 @@ main > h2 {
.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. */
@@ -368,6 +439,12 @@ main > h2 {
background: var(--bg-panel-hover); background: var(--bg-panel-hover);
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
/* Keep the toolbar in reach on long pages: stick it to the top of the
center column's scroll viewport (just under the header). Mobile overrides
this to a keyboard-hugging bottom bar (see responsive block). */
position: sticky;
top: 0;
z-index: 30;
} }
.editor-toolbar::-webkit-scrollbar { display: none; } .editor-toolbar::-webkit-scrollbar { display: none; }
.editor-toolbar > * { flex-shrink: 0; } .editor-toolbar > * { flex-shrink: 0; }
@@ -380,14 +457,22 @@ main > h2 {
/* === Edit form === */ /* === Edit form === */
.edit-form { display: flex; flex-direction: column; } .edit-form { display: flex; flex-direction: column; }
/* The sidebar is always empty while editing, so the editor uses the full /* No rails are rendered while editing, so the editor uses the full center
viewport: drop the reserved 14rem sidebar track and the centered max-width. */ column: drop the reading-width cap on main (and the footer) so the toolbar
body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: none; } and CodeMirror mount span the whole width. */
body.edit-mode main,
body.edit-mode footer { max-width: none; }
/* CodeMirror mount. The .cm-editor visual treatment (border, bg, font, padding) /* CodeMirror mount. The .cm-editor visual treatment (border, bg, font, padding)
lives in the CM theme (editor-build/entry.js), keyed off the same :root lives in the CM theme (editor-build/entry.js), keyed off the same :root
variables; this only sizes the container. */ variables; this only sizes the container. */
.editor-cm { min-height: 60vh; } .editor-cm { min-height: 60vh; }
.editor-cm .cm-editor { height: 100%; } .editor-cm .cm-editor { height: 100%; }
/* Stretch the editable surface to fill the mount. Without this the
contenteditable is only as tall as the text, so clicking in the empty region
below a short document lands on the (non-editable) scroller and places no
caret. Filling it means a click anywhere maps to the nearest position — the
end of the document. */
.editor-cm .cm-content { min-height: 60vh; }
/* === Search === */ /* === Search === */
.search-form { .search-form {
@@ -396,28 +481,23 @@ body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: no
gap: var(--space-1); gap: var(--space-1);
position: relative; position: relative;
justify-self: center; justify-self: center;
width: 24rem; width: 100%;
max-width: 100%; max-width: 40rem;
} }
.search-input { font-size: 0.9rem; } .search-input { font-size: 0.9rem; }
.search-card { .search-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-1); gap: var(--space-1);
padding-bottom: var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
border-bottom: var(--border-dashed);
word-break: break-word; word-break: break-word;
} }
.search-card:last-child { border-bottom: none; }
.search-card a { color: var(--link); font-size: 1.1rem; } .search-card a { color: var(--link); font-size: 1.1rem; }
.search-card a:hover { color: var(--link-hover); } .search-card a:hover { color: var(--link-hover); }
.search-section {
/* === Floating action button === padding-bottom: var(--space-2);
Standalone FAB buttons (page TOC) are mobile-only. Wrapped FABs (search border-bottom: var(--border-dashed);
actions dropdown) stay visible on desktop. */ }
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
button.fab { display: none; }
/* === Companion status === */ /* === Companion status === */
.companion-status { margin-left: auto; } .companion-status { margin-left: auto; }
@@ -470,6 +550,24 @@ button.fab { display: none; }
display: block; display: block;
background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat; background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
} }
/* Wrapper so a video tile can overlay a play badge on its extracted frame. */
.thumb-media { position: relative; display: block; }
.thumb-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding-left: 0.15rem;
border-radius: 50%;
background: var(--overlay);
color: var(--text);
pointer-events: none;
}
.thumb-icon { .thumb-icon {
height: 150px; height: 150px;
display: flex; display: flex;
@@ -479,47 +577,37 @@ 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 === */
.sidebar { .sidebar {
position: sticky; width: 14rem;
top: var(--space-4); flex-shrink: 0;
align-self: start; overflow-y: auto;
max-height: calc(100vh - 2rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
margin-top: var(--space-5); padding: var(--space-4) 0 var(--space-4) var(--space-2);
border-left: var(--border-dashed);
} }
aside.sidebar:empty { display: none; } aside.sidebar:empty { display: none; }
.panel-sidebar {
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
The .panel class applied alongside provides the border + background. */
.panel-sidebar { padding: var(--space-2) var(--space-3); font-size: var(--font-sm); }
.actions { display: flex; flex-direction: column; gap: 0.15rem; }
/* === Table of contents (floating variant) ===
Default rendering is a fixed floating panel; inside .sidebar it becomes
static via the .panel-sidebar density class. */
.toc {
position: fixed;
top: var(--space-4);
right: var(--space-4);
width: 14rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
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;
}
/* === Table of contents ===*/
.toc {
float: right;
width: 15rem;
max-width: 45%;
margin: 0 0 var(--space-3) var(--space-4);
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
max-width: 60vh;
overflow-y: auto;
} }
.sidebar .toc { position: static; width: auto; max-height: none; }
.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 {
@@ -570,35 +658,107 @@ aside.sidebar:empty { display: none; }
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
} }
/* === Overlay (full-viewport rail host, mobile) ===
A distinct component from .modal: it hosts a whole rail (the folder tree or
the right rail's TOC/widgets) at full screen with its own scroll and a
top-right close control. overlay.js MOVES the rail node into .overlay-body,
so the rules below strip the rail's column chrome (fixed width, borders, the
mobile display:none) and let it render as plain full-width content. */
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.overlay-bar {
display: flex;
justify-content: flex-end;
padding: var(--space-2);
border-bottom: var(--border-dashed);
flex-shrink: 0;
}
.overlay-close {
width: 2.5rem;
height: 2.5rem;
font-size: 1.5rem;
}
.overlay-close::before, .overlay-close::after { content: none; }
.overlay-close:hover { background: var(--bg-panel-hover); color: var(--primary-hover); }
.overlay-body {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
-webkit-overflow-scrolling: touch;
}
.overlay-body .tree-scroll,
.overlay-body .sidebar {
display: block;
width: auto;
max-height: none;
border: none;
padding: 0;
overflow: visible;
}
/* Right rail keeps its column spacing (TOC above widgets) inside the overlay;
the base .sidebar already sets flex-direction + gap. */
.overlay-body .sidebar { display: flex; }
/* === Tree picker === */ /* === Tree picker === */
.tree-picker { max-height: 60vh; overflow-y: auto; } /* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave,
matching the persistent .tree-sidebar rail. */
.tree-picker { max-height: 60vh; overflow-y: auto; user-select: none; }
.tree-row { .tree-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-1);
padding: 0.4rem var(--space-2); padding: 0.25rem var(--space-1);
cursor: pointer; cursor: pointer;
min-height: 2rem;
} }
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); } .tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
.tree-row.is-selected { .tree-row.is-selected {
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, .tree-marker { 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-marker { width: var(--space-4); color: var(--text-muted); } .tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tree-children {
.tree-children { padding-left: var(--space-5); } margin-left: var(--space-2);
padding-left: var(--space-2);
border-left: var(--border-guide);
}
.tree-selected-path { .tree-selected-path {
font-size: var(--font-sm); font-size: var(--font-sm);
padding: var(--space-1) 0; padding: var(--space-1) 0;
word-break: break-all; word-break: break-all;
} }
/* Tree sidebar (persistent navigation) */
.tree-sidebar {
width: var(--tree-width);
flex-shrink: 0;
min-height: 0;
padding: var(--space-4) 0 var(--space-4) var(--space-4);
border-right: var(--border-dashed);
}
.tree-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: var(--space-2);
font-size: var(--font-sm);
user-select: none;
}
/* === Movie info box === */ /* === Movie info box === */
.movie-info { margin: var(--space-3) 0; } .movie-info { margin: var(--space-3) 0; }
.movie-info::after { content: ""; display: block; clear: both; } .movie-info::after { content: ""; display: block; clear: both; }
@@ -617,23 +777,17 @@ aside.sidebar:empty { display: none; }
} }
/* === Diary calendar === */ /* === Diary calendar === */
.diary-cal-nav { /* Header row: "Chronological" on the left, the [year ▾] dropdown pushed to the
display: flex; right. Composes with .row (flex + centering); space-between splits the two. */
align-items: center; .diary-cal .panel-header { justify-content: space-between; }
justify-content: center; .diary-cal-drop .dropdown-menu { min-width: 0; }
gap: 0.2rem; /* Per-month caption above each grid, linking to that month's anchor. */
margin-bottom: 0.4rem; .diary-cal-month {
font-size: var(--font-sm); font-size: var(--font-sm);
margin: var(--space-3) 0 0.2rem;
} }
.diary-cal-nav .diary-cal-drop + .diary-cal-heading { margin-left: var(--space-3); } .diary-cal-month a { color: var(--link); }
/* Anchor the month/year dropdowns to the nav row instead of the ▾ button so .diary-cal-month a:hover { color: var(--link-hover); }
the menu spans the full panel width rather than overflowing the 14rem
sidebar with its default min-width: 9rem. */
.diary-cal-nav { position: relative; }
.diary-cal-nav .diary-cal-drop { position: static; }
.diary-cal-nav .dropdown-menu { left: 0; right: 0; min-width: 0; }
.diary-cal-heading { color: var(--link); }
.diary-cal-heading:hover { color: var(--link-hover); }
.diary-cal-grid { .diary-cal-grid {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -679,40 +833,32 @@ aside.sidebar:empty { display: none; }
/* === Responsive === */ /* === Responsive === */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.page-wrap { grid-template-columns: 1fr; } .tree-sidebar, .sidebar { display: none; }
/* Single-row mobile header: logo + Up icon are compact so search can
take the middle flex column, with actions on the 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; }
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside .actions-menu .btn-block {
itself is the scroll container; children render at natural height. */ min-height: 2.75rem;
.sidebar { padding: 0.8rem var(--space-4);
position: fixed; border-top: var(--border-dashed);
bottom: 5rem;
right: var(--space-4);
top: auto;
left: auto;
width: calc(100% - 2rem);
max-width: 20rem;
max-height: calc(100vh - 8rem);
overflow-y: auto;
display: none;
z-index: 60;
} }
.sidebar.is-open { display: flex; } .actions-menu > .btn-block:first-child,
button.fab { display: inline-flex; } .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) {
header, footer { padding: var(--space-2) var(--space-3); } header, footer { padding: var(--space-2) var(--space-3); }
main { padding: var(--space-4) var(--space-3); } main { padding: var(--space-4) var(--space-3); }
.app-name {display: none;}
.editor-cm { min-height: 50vh; } .editor-cm { min-height: 50vh; }
.sidebar { width: calc(100% - 1.5rem); }
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
and editor use the entire viewport width. */
body.edit-mode .page-wrap { padding: 0; gap: 0; }
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;
@@ -722,15 +868,11 @@ 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;
top: auto;
bottom: 0; bottom: 0;
z-index: 50; z-index: 50;
border: none; border: none;
@@ -740,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; }
@@ -755,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; }
} }
-8
View File
@@ -85,11 +85,6 @@
} }
row.appendChild(chevron); row.appendChild(chevron);
var marker = document.createElement('span');
marker.className = 'tree-marker';
marker.textContent = kind === 'folder' ? '' : '\u00b7';
row.appendChild(marker);
var label = document.createElement('span'); var label = document.createElement('span');
label.className = 'tree-name'; label.className = 'tree-name';
label.textContent = name; label.textContent = name;
@@ -199,9 +194,6 @@
var rootChev = document.createElement('span'); var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf'; rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev); rootRow.appendChild(rootChev);
var rootMarker = document.createElement('span');
rootMarker.className = 'tree-marker';
rootRow.appendChild(rootMarker);
var rootLabel = document.createElement('span'); var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name'; rootLabel.className = 'tree-name';
rootLabel.textContent = '/'; rootLabel.textContent = '/';
+215
View File
@@ -0,0 +1,215 @@
;(function () {
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
if (!container) return
function joinPath(parent, name) {
if (parent === "/" || parent === "") return "/" + name
return parent.replace(/\/+$/, "") + "/" + name
}
function encodeSegments(p) {
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
}
function folderHref(p) {
if (p === "/" || p === "") return "/"
return "/" + encodeSegments(p) + "/"
}
function fetchFolder(path, expandTo) {
var url = folderHref(path) + "?tree=1&foldersOnly=1"
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
return fetch(url, { credentials: "same-origin" }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status)
return r.json()
})
}
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
// thus fullPath) are decoded, so we must decode here too or paths containing
// spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, "")
if (p === "") return "/"
try {
return decodeURIComponent(p)
} catch (e) {
return p
}
})()
var activeRow = null
function disabledRow(text) {
var row = document.createElement("div")
row.className = "tree-row is-disabled"
row.textContent = text
return row
}
// renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) {
entries.forEach(function (entry) {
var built = buildRow(parentPath, entry)
containerEl.appendChild(built.row)
if (built.preExpand) built.open()
})
}
function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name)
var row = document.createElement("div")
row.className = "tree-row"
var chevron = document.createElement("span")
chevron.className = "tree-chevron"
chevron.textContent = "▸"
row.appendChild(chevron)
var link = document.createElement("a")
link.className = "tree-name tree-folder"
link.textContent = entry.name
link.href = folderHref(fullPath)
row.appendChild(link)
if (fullPath === activePath) {
row.classList.add("is-active")
activeRow = row
}
var preChildren = entry.children ? entry.children : null
var childrenEl = null
var loaded = false
var isOpen = false
var isLeaf = false
function ensureChildrenEl() {
if (!childrenEl) {
childrenEl = document.createElement("div")
childrenEl.className = "tree-children"
}
return childrenEl
}
function markLeaf() {
isLeaf = true
isOpen = false
chevron.textContent = "▸"
chevron.classList.add("is-leaf")
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
}
function loadChildren() {
var el = ensureChildrenEl()
el.textContent = ""
el.appendChild(disabledRow("…"))
fetchFolder(fullPath)
.then(function (resp) {
el.textContent = ""
loaded = true
if (!resp.entries || resp.entries.length === 0) {
markLeaf()
return
}
renderInto(el, fullPath, resp.entries)
})
.catch(function () {
el.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function (e) {
e.stopPropagation()
loadChildren()
})
el.appendChild(err)
})
}
function open() {
if (isOpen || isLeaf) return
var el = ensureChildrenEl()
row.parentNode.insertBefore(el, row.nextSibling)
chevron.textContent = "▾" // ▾
isOpen = true
if (!loaded) {
if (preChildren) {
renderInto(el, fullPath, preChildren)
loaded = true
} else {
loadChildren()
}
}
}
function close() {
if (!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 (isOpen) close()
else open()
})
return { row: row, open: open, preExpand: !!preChildren }
}
function render() {
container.textContent = ""
var listing = document.createElement("div")
container.appendChild(listing)
listing.appendChild(disabledRow("…"))
fetchFolder("/", activePath === "/" ? "" : activePath)
.then(function (resp) {
listing.textContent = ""
renderInto(listing, "/", resp.entries)
if (activeRow) {
try {
activeRow.scrollIntoView({ block: "nearest" })
} catch (e) {}
}
})
.catch(function () {
listing.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function () {
render()
})
listing.appendChild(err)
})
}
function setupMenuItem() {
var specials = document.querySelector(".actions-special")
if (!specials) return
var item = document.createElement("button")
item.type = "button"
item.className = "btn btn-block"
item.title = "Folders"
item.textContent = "FOLDERS"
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(container)
})
specials.appendChild(item)
}
render()
setupMenuItem()
})()
+699
View File
@@ -0,0 +1,699 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
)
// JSON Canvas (https://jsoncanvas.org/spec/1.0/) support.
//
// A canvas is a `<Name>.canvas` file sitting in a page folder, addressed as an
// alternative *view* of that page rather than as a standalone file:
//
// /Topics/Ideas/?canvas=Architecture the canvas view
// /Topics/Ideas/Architecture.canvas 302 -> the line above
// /Topics/Ideas/Architecture.canvas?raw the file bytes
//
// Routing through the folder keeps the page's identity (breadcrumb, tree,
// header actions) and lets every canvas in a folder render as a tab strip above
// the page content. The redirect mirrors how diary.go collapses its virtual
// month/day URLs onto one canonical form.
//
// The server owns the format: it parses and validates on every read and write,
// re-marshals from its own structs on save, and never writes bytes it has not
// round-tripped through canvasDoc. Keys it does not model are dropped rather
// than preserved — this wiki is the only writer.
const canvasExt = ".canvas"
// canvasMaxBytes caps a save payload. A canvas is coordinates and prose; a
// megabyte is already far past any hand-built diagram and well short of
// anything that could pressure the NAS.
const canvasMaxBytes = 4 << 20
// canvasRenderMaxBytes caps a /_canvas/render body. Only one node's content
// travels at a time.
const canvasRenderMaxBytes = 1 << 20
// Structural caps. These exist so a looping client cannot grow a file until it
// stops loading, not because the format has limits.
const (
canvasMaxNodes = 5000
canvasMaxEdges = 10000
canvasMaxCoord = 1 << 20
)
// canvasNameRe restricts a ?canvas= value to a single safe path component: the
// name is joined onto the folder path, so separators, dot-segments, control
// characters, and leading dots (which would create a hidden file the listing
// filters away) must all be rejected before the filesystem sees it.
var canvasNameRe = regexp.MustCompile(`^[^./\\\x00-\x1f][^/\\\x00-\x1f]{0,99}$`)
// canvasHexRe matches the hex form of a canvasColor. The spec also allows the
// preset digits "1".."6"; see validCanvasColor.
var canvasHexRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// Node types defined by the spec.
const (
canvasNodeText = "text"
canvasNodeFile = "file"
canvasNodeLink = "link"
canvasNodeGroup = "group"
)
// canvasDoc is a whole `.canvas` file. Both arrays are optional per the spec,
// so an empty document marshals to `{}`.
type canvasDoc struct {
Nodes []canvasNode `json:"nodes,omitempty"`
Edges []canvasEdge `json:"edges,omitempty"`
}
// canvasNode carries every node variant in one struct. The spec's four types
// share six required attributes and add one to three of their own, so a flat
// struct with omitempty round-trips all of them without the ceremony of a
// custom unmarshaller; validate() enforces which extras a given Type may use.
type canvasNode struct {
ID string `json:"id"`
Type string `json:"type"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Color string `json:"color,omitempty"`
Text string `json:"text,omitempty"` // type=text
File string `json:"file,omitempty"` // type=file
Subpath string `json:"subpath,omitempty"` // type=file
URL string `json:"url,omitempty"` // type=link
Label string `json:"label,omitempty"` // type=group
Background string `json:"background,omitempty"` // type=group
BackgroundStyle string `json:"backgroundStyle,omitempty"` // type=group
}
type canvasEdge struct {
ID string `json:"id"`
FromNode string `json:"fromNode"`
FromSide string `json:"fromSide,omitempty"`
FromEnd string `json:"fromEnd,omitempty"` // defaults to "none"
ToNode string `json:"toNode"`
ToSide string `json:"toSide,omitempty"`
ToEnd string `json:"toEnd,omitempty"` // defaults to "arrow"
Color string `json:"color,omitempty"`
Label string `json:"label,omitempty"`
}
// canvasRef is one entry in the page's view switcher.
type canvasRef struct {
Name string
URL string
Active bool
}
// canvasPayload is the single blob inlined into the canvas page. Shipping the
// document *and* its server-rendered node HTML together means opening a canvas
// costs exactly one request no matter how many nodes it holds — the obvious
// alternative (one content request per node on load) fans out badly over the
// mobile/VPN path.
type canvasPayload struct {
Name string `json:"name"`
PostURL string `json:"postUrl"`
Hash string `json:"hash"`
Doc *canvasDoc `json:"doc"`
Rendered map[string]template.HTML `json:"rendered"`
}
// --- parsing and validation ---
// parseCanvas unmarshals and validates raw canvas bytes. Unknown keys are
// ignored rather than rejected so a hand-edited file with a stray attribute
// still opens; they are dropped on the next save.
func parseCanvas(raw []byte) (*canvasDoc, error) {
doc := &canvasDoc{}
if len(strings.TrimSpace(string(raw))) == 0 {
return doc, nil
}
if err := json.Unmarshal(raw, doc); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if err := doc.validate(); err != nil {
return nil, err
}
return doc, nil
}
// validate rejects anything that would render as a broken or invisible canvas.
// It runs on read as well as write: a file edited by hand outside the app gets
// the same diagnosis the editor would give, naming the offending node.
func (c *canvasDoc) validate() error {
if len(c.Nodes) > canvasMaxNodes {
return fmt.Errorf("too many nodes (%d, max %d)", len(c.Nodes), canvasMaxNodes)
}
if len(c.Edges) > canvasMaxEdges {
return fmt.Errorf("too many edges (%d, max %d)", len(c.Edges), canvasMaxEdges)
}
ids := make(map[string]bool, len(c.Nodes))
for i := range c.Nodes {
n := &c.Nodes[i]
if n.ID == "" {
return fmt.Errorf("node %d: missing id", i)
}
if ids[n.ID] {
return fmt.Errorf("duplicate node id %q", n.ID)
}
ids[n.ID] = true
switch n.Type {
case canvasNodeText:
// An empty text node is a node the user just created and has not
// typed into yet, so emptiness is allowed here even though the spec
// calls `text` required. A missing *file* or *url*, by contrast, is
// a node that can never render anything.
case canvasNodeFile:
if n.File == "" {
return fmt.Errorf("node %q: file node has no file", n.ID)
}
if n.Subpath != "" && !strings.HasPrefix(n.Subpath, "#") {
return fmt.Errorf("node %q: subpath must start with #", n.ID)
}
case canvasNodeLink:
if n.URL == "" {
return fmt.Errorf("node %q: link node has no url", n.ID)
}
case canvasNodeGroup:
if n.BackgroundStyle != "" {
switch n.BackgroundStyle {
case "cover", "ratio", "repeat":
default:
return fmt.Errorf("node %q: bad backgroundStyle %q", n.ID, n.BackgroundStyle)
}
}
case "":
return fmt.Errorf("node %q: missing type", n.ID)
default:
return fmt.Errorf("node %q: unknown type %q", n.ID, n.Type)
}
if n.Width <= 0 || n.Height <= 0 {
return fmt.Errorf("node %q: width and height must be positive", n.ID)
}
if n.Width > canvasMaxCoord || n.Height > canvasMaxCoord ||
abs(n.X) > canvasMaxCoord || abs(n.Y) > canvasMaxCoord {
return fmt.Errorf("node %q: coordinates out of range", n.ID)
}
if !validCanvasColor(n.Color) {
return fmt.Errorf("node %q: bad color %q", n.ID, n.Color)
}
}
edgeIDs := make(map[string]bool, len(c.Edges))
for i := range c.Edges {
e := &c.Edges[i]
if e.ID == "" {
return fmt.Errorf("edge %d: missing id", i)
}
if edgeIDs[e.ID] {
return fmt.Errorf("duplicate edge id %q", e.ID)
}
edgeIDs[e.ID] = true
if !ids[e.FromNode] {
return fmt.Errorf("edge %q: fromNode %q does not exist", e.ID, e.FromNode)
}
if !ids[e.ToNode] {
return fmt.Errorf("edge %q: toNode %q does not exist", e.ID, e.ToNode)
}
if !validCanvasSide(e.FromSide) {
return fmt.Errorf("edge %q: bad fromSide %q", e.ID, e.FromSide)
}
if !validCanvasSide(e.ToSide) {
return fmt.Errorf("edge %q: bad toSide %q", e.ID, e.ToSide)
}
if !validCanvasEnd(e.FromEnd) {
return fmt.Errorf("edge %q: bad fromEnd %q", e.ID, e.FromEnd)
}
if !validCanvasEnd(e.ToEnd) {
return fmt.Errorf("edge %q: bad toEnd %q", e.ID, e.ToEnd)
}
if !validCanvasColor(e.Color) {
return fmt.Errorf("edge %q: bad color %q", e.ID, e.Color)
}
}
return nil
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// validCanvasColor accepts an absent color, a preset digit "1".."6" (red,
// orange, yellow, green, cyan, purple), or a hex string.
func validCanvasColor(c string) bool {
switch c {
case "", "1", "2", "3", "4", "5", "6":
return true
}
return canvasHexRe.MatchString(c)
}
func validCanvasSide(s string) bool {
switch s {
case "", "top", "right", "bottom", "left":
return true
}
return false
}
func validCanvasEnd(e string) bool {
switch e {
case "", "none", "arrow":
return true
}
return false
}
// canvasHash is the conflict token exchanged with the client. It covers the
// exact bytes on disk, so any edit made outside this editor invalidates it.
func canvasHash(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])[:16]
}
// --- paths and discovery ---
func canvasPath(fsPath, name string) string {
return filepath.Join(fsPath, name+canvasExt)
}
// canvasBackupPath is the previous-contents copy kept beside a canvas. The dot
// prefix keeps it out of the file listing and the search index.
func canvasBackupPath(fsPath, name string) string {
return filepath.Join(fsPath, "."+name+canvasExt+".bak")
}
// canvasURL builds the canonical view URL for a canvas in the folder at urlPath.
func canvasURL(urlPath, name string) string {
if !strings.HasSuffix(urlPath, "/") {
urlPath += "/"
}
return urlPath + "?canvas=" + url.QueryEscape(name)
}
// canvasViewURL maps a direct `/A/B/Name.canvas` request onto the canonical
// `/A/B/?canvas=Name` form. Segments are re-encoded because urlPath arrives
// already percent-decoded.
func canvasViewURL(urlPath string) (string, bool) {
base := path.Base(urlPath)
name := strings.TrimSuffix(base, canvasExt)
if name == "" || name == base || !canvasNameRe.MatchString(name) {
return "", false
}
return canvasURL(fileURL(strings.Trim(path.Dir(urlPath), "/")), name), true
}
// listCanvases returns the canvases in a folder as view-switcher tabs, sorted
// case-insensitively by name. active marks the one currently being viewed.
func listCanvases(fsPath, urlPath, active string) []canvasRef {
entries, err := os.ReadDir(fsPath)
if err != nil {
return nil
}
var refs []canvasRef
for _, e := range entries {
name := e.Name()
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, canvasExt) {
continue
}
base := strings.TrimSuffix(name, canvasExt)
if !canvasNameRe.MatchString(base) {
continue
}
refs = append(refs, canvasRef{
Name: base,
URL: canvasURL(urlPath, base),
Active: base == active,
})
}
sort.Slice(refs, func(i, j int) bool {
return strings.ToLower(refs[i].Name) < strings.ToLower(refs[j].Name)
})
return refs
}
// isCanvasFile reports whether a listing entry is a canvas. Used to keep
// canvases out of the Files listing — they are surfaced as tabs above the
// content instead, the same reasoning that hides index.md.
func isCanvasFile(name string) bool {
return strings.HasSuffix(name, canvasExt)
}
// --- server-side node rendering ---
// prerenderNodes renders every node whose content the server owns, keyed by
// node id. Link and group nodes are built entirely in the browser and are
// absent from the map.
func (h *handler) prerenderNodes(c *canvasDoc) map[string]template.HTML {
out := make(map[string]template.HTML, len(c.Nodes))
for i := range c.Nodes {
n := &c.Nodes[i]
switch n.Type {
case canvasNodeText:
out[n.ID] = renderMarkdown([]byte(n.Text))
case canvasNodeFile:
out[n.ID] = h.renderFileNode(n.File, n.Subpath)
}
}
return out
}
// renderFileNode renders a file node's transclusion. Markdown is rendered
// through the shared goldmark instance so wikilinks, embeds, tables, and task
// checkboxes behave exactly as they do on a page; media gets a player or an
// <img> pointed at the existing thumbnail endpoint; anything else degrades to
// a link. A missing target renders visibly broken rather than blank.
func (h *handler) renderFileNode(file, subpath string) template.HTML {
target := "/" + strings.Trim(file, "/")
fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") {
return canvasBrokenRef(file, "outside the wiki")
}
info, err := os.Stat(fsPath)
if err != nil {
return canvasBrokenRef(file, "not found")
}
if info.IsDir() {
// A folder is a page; transclude its markdown.
fsPath = filepath.Join(fsPath, "index.md")
if _, err := os.Stat(fsPath); err != nil {
return canvasBrokenRef(file, "page has no content")
}
return h.renderMarkdownFile(fsPath, file, subpath)
}
name := path.Base(target)
href := html.EscapeString(fileURL(strings.TrimPrefix(target, "/")))
switch {
case isImageFile(name):
return template.HTML(`<a class="canvas-media" href="` + href + `"><img src="` +
html.EscapeString(thumbURL(fileURL(strings.TrimPrefix(target, "/")), 800)) + `" alt="" loading="lazy"></a>`)
case isVideoFile(name):
return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`)
case strings.EqualFold(path.Ext(name), ".pdf"):
return template.HTML(`<embed class="canvas-media" type="application/pdf" src="` + href + `">`)
case strings.EqualFold(path.Ext(name), ".md"):
return h.renderMarkdownFile(fsPath, file, subpath)
default:
return template.HTML(`<p class="canvas-file-fallback"><a href="` + href + `">` +
html.EscapeString(name) + `</a></p>`)
}
}
// renderMarkdownFile renders a markdown file, optionally narrowed to the
// section named by subpath (`#Heading`). The narrowing reuses the same section
// machinery the page editor uses, so a heading spans its subsections.
func (h *handler) renderMarkdownFile(fsPath, file, subpath string) template.HTML {
raw, err := os.ReadFile(fsPath)
if err != nil {
return canvasBrokenRef(file, "unreadable")
}
if subpath != "" {
section, ok := canvasSubpathSection(raw, subpath)
if !ok {
return canvasBrokenRef(file+subpath, "no such heading")
}
raw = section
}
return renderMarkdown(raw)
}
// canvasSubpathSection narrows raw markdown to the section whose heading
// matches subpath. The match accepts either the heading text (what a human
// types) or goldmark's generated anchor id (what a copied link contains).
func canvasSubpathSection(raw []byte, subpath string) ([]byte, bool) {
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
if want == "" {
return raw, true
}
sections := splitSections(raw)
ids := headingIDs(raw)
for i := 1; i < len(sections); i++ {
_, text := sectionHeading(sections[i])
id := ""
if i-1 < len(ids) {
id = ids[i-1]
}
if strings.EqualFold(text, want) || id == want {
return joinSections(sections[i:secionSpanEnd(sections, i)]), true
}
}
return nil, false
}
func canvasBrokenRef(target, reason string) template.HTML {
return template.HTML(`<p class="canvas-broken">` + html.EscapeString(target) +
` <span class="muted">(` + html.EscapeString(reason) + `)</span></p>`)
}
// --- request handlers ---
// serveCanvas renders the canvas view of a page. Reached from serveDir when
// the request carries ?canvas=<name>.
func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if !canvasNameRe.MatchString(name) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
raw, err := os.ReadFile(canvasPath(fsPath, name))
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
data := pageData{
Title: name + " — " + pageTitle(urlPath),
CanEdit: true,
IsRoot: urlPath == "/",
SectionIndex: -1,
InsertBefore: -1,
PostURL: urlPath,
ActiveCanvas: name,
Canvases: listCanvases(fsPath, urlPath, name),
}
// A parse failure is reported in-page rather than as a bare error: the file
// is hand-editable, so the user needs to see which node is wrong and still
// reach the raw bytes.
doc, parseErr := parseCanvas(raw)
if parseErr != nil {
data.CanvasError = parseErr.Error()
} else {
payload := canvasPayload{
Name: name,
PostURL: urlPath,
Hash: canvasHash(raw),
Doc: doc,
Rendered: h.prerenderNodes(doc),
}
// encoding/json escapes <, >, & and the U+2028/U+2029 line separators,
// so the result is safe to inline verbatim in a <script> block.
blob, err := json.Marshal(payload)
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
data.CanvasJSON = template.JS(blob)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err)
}
}
// handleCanvasPost dispatches every POST carrying ?canvas=. It is checked
// before the page-level delete/move actions in handlePost so `?canvas=X&delete`
// removes the canvas rather than the whole page.
func (h *handler) handleCanvasPost(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if !canvasNameRe.MatchString(name) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
query := r.URL.Query()
switch {
case query.Has("create"):
h.handleCanvasCreate(w, r, urlPath, fsPath, name)
case query.Has("delete"):
h.handleCanvasDelete(w, r, urlPath, fsPath, name)
case query.Has("rename"):
h.handleCanvasRename(w, r, urlPath, fsPath, name, query.Get("rename"))
default:
h.handleCanvasSave(w, r, urlPath, fsPath, name)
}
}
// handleCanvasSave replaces a canvas with the posted document. The write is
// gated on four checks in order — parse, validate, version, non-empty — so a
// malformed or stale payload never reaches the file.
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasMaxBytes))
if err != nil {
http.Error(w, "request too large or truncated", http.StatusBadRequest)
return
}
doc, err := parseCanvas(body)
if err != nil {
http.Error(w, "rejected: "+err.Error(), http.StatusBadRequest)
return
}
p := canvasPath(fsPath, name)
current, readErr := os.ReadFile(p)
if readErr != nil && !os.IsNotExist(readErr) {
http.Error(w, "read failed: "+readErr.Error(), http.StatusInternalServerError)
return
}
if want := r.Header.Get("X-Canvas-Version"); want != "" && want != canvasHash(current) {
http.Error(w, "the canvas changed since you opened it — reload to get the current version",
http.StatusConflict)
return
}
// A save must never blank a canvas. An empty document is indistinguishable
// from a client that lost its state, so emptying is only allowed when the
// caller says so explicitly — the same reasoning as the empty-content guard
// on page saves.
if len(doc.Nodes) == 0 && !r.URL.Query().Has("empty") {
if cur, err := parseCanvas(current); err == nil && len(cur.Nodes) > 0 {
http.Error(w, fmt.Sprintf("refusing to replace %d nodes with an empty canvas", len(cur.Nodes)),
http.StatusBadRequest)
return
}
}
out, err := json.MarshalIndent(doc, "", "\t")
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
out = append(out, '\n')
if len(current) > 0 {
if err := writeFileAtomic(canvasBackupPath(fsPath, name), current, 0644); err != nil {
log.Printf("canvas backup %s: %v", p, err)
}
}
if err := writeFileAtomic(p, out, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Hand back the new token so the client can keep editing without reloading.
w.Header().Set("X-Canvas-Version", canvasHash(out))
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.MkdirAll(fsPath, 0755); err != nil {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
p := canvasPath(fsPath, name)
if _, err := os.Stat(p); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := writeFileAtomic(p, []byte("{}\n"), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
}
func (h *handler) handleCanvasDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.Remove(canvasPath(fsPath, name)); err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
func (h *handler) handleCanvasRename(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name, newName string) {
if !canvasNameRe.MatchString(newName) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
if newName == name {
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
return
}
dst := canvasPath(fsPath, newName)
if _, err := os.Stat(dst); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := os.Rename(canvasPath(fsPath, name), dst); err != nil {
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, canvasURL(urlPath, newName), http.StatusSeeOther)
}
// handleCanvasRender renders one node's content on demand. It is stateless and
// takes the content in the request rather than reading it from the canvas file:
// the node being re-rendered has just been edited in the browser and is not on
// disk yet. Everything already on disk arrives pre-rendered with the page.
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, canvasRenderMaxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var out template.HTML
switch r.FormValue("type") {
case canvasNodeFile:
out = h.renderFileNode(r.FormValue("file"), r.FormValue("subpath"))
case canvasNodeText:
out = renderMarkdown([]byte(r.FormValue("text")))
default:
http.Error(w, "bad node type", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, string(out))
}
+3 -1
View File
@@ -57,7 +57,9 @@ func runOpenCommand(template, path string) error {
if !sawPath { if !sawPath {
tokens = append(tokens, path) tokens = append(tokens, path)
} }
return exec.Command(tokens[0], tokens[1:]...).Start() cmd := exec.Command(tokens[0], tokens[1:]...)
hideConsole(cmd)
return cmd.Start()
} }
// tokenizeCommand splits a command-line string into argv tokens, honouring // tokenizeCommand splits a command-line string into argv tokens, honouring
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package main
import "os/exec"
// Allows running commands without showing a terminal window on startup when not running on windows
func hideConsole(*exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"os/exec"
"syscall"
)
// This is the windows CREATE_NO_WINDOW syscall.
// We have no golang.org/x/sys dependency, so this will suffice
const createNoWindow = 0x08000000
// Allows running commands without showing a terminal window on startup on windows
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNoWindow,
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
) )
const version = "1" const version = "2"
func main() { func main() {
flag.Parse() flag.Parse()
+16 -45
View File
@@ -10,7 +10,6 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -28,7 +27,8 @@ type diaryHandler struct{}
// page anchor (or to the year-file editor when ?edit is set). // page anchor (or to the year-file editor when ?edit is set).
// //
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a // 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
// year+anchor target (or insert flow when today's section is missing). // year+anchor target (never the editor, even when today's section does
// not exist yet).
// 2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to // 2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to
// /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=N when ?edit is set). // /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=N when ?edit is set).
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to // 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
@@ -57,7 +57,7 @@ func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (strin
parentFS := filepath.Dir(fsPath) parentFS := filepath.Dir(fsPath)
parentURLPath := parentURL(urlPath) parentURLPath := parentURL(urlPath)
_, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath) _, _, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
if !ok { if !ok {
return "", false return "", false
} }
@@ -71,16 +71,7 @@ func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (strin
switch base { switch base {
case "today": case "today":
dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day) dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
raw, _ := os.ReadFile(filepath.Join(diaryRootFS, year, "index.md")) return yearURL + "#" + dayHeading, true
sections := splitSections(raw)
if _, found := findSectionIndex(sections, dayHeading); found {
return yearURL + "#" + dayHeading, true
}
// Missing day: route through the insert flow so today's section
// is spliced in at the right chronological position.
insertIdx := computeInsertIndex(sections, dayHeading)
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
yearURL, insertIdx, url.QueryEscape(dayHeading)), true
case "this-month": case "this-month":
return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
case "this-year": case "this-year":
@@ -216,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) {
@@ -343,8 +318,8 @@ type calYear struct {
} }
// calMonthGrid carries everything the template needs to render one month's // calMonthGrid carries everything the template needs to render one month's
// grid plus the dropdown / heading entry that targets it. The calendar // grid plus the caption that links to it. The calendar widget renders all 12
// widget ships all 12 in the initial HTML; JS swaps which one is visible. // stacked vertically; JS centers the current month in the rail on load.
type calMonthGrid struct { type calMonthGrid struct {
Num int Num int
Name string Name string
@@ -353,13 +328,11 @@ type calMonthGrid struct {
} }
type calendarData struct { type calendarData struct {
DisplayYear int DisplayYear int
DisplayMonth int DisplayMonth int
DisplayMonthName string // pre-resolved so the template doesn't need arithmetic DiaryURL string
DiaryURL string Months []calMonthGrid
YearURL string Years []calYear
Months []calMonthGrid
Years []calYear
} }
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html")) var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
@@ -489,13 +462,11 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num }) sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
data := calendarData{ data := calendarData{
DisplayYear: displayYear, DisplayYear: displayYear,
DisplayMonth: displayMonth, DisplayMonth: displayMonth,
DisplayMonthName: months[displayMonth-1].Name, DiaryURL: diaryRootURL,
DiaryURL: diaryRootURL, Months: months,
YearURL: yearURL, Years: years,
Months: months,
Years: years,
} }
var buf bytes.Buffer var buf bytes.Buffer
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"bytes"
"path"
"strconv"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// Embeds are ordinary Markdown images: ![caption](photo.jpg "left").
//
// Destinations are relative to the page they sit on. Pages always render at a
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
// resolves the destination and nothing here builds a URL or touches the
// filesystem. Goldmark's own image parser produces the nodes; only rendering
// is overridden. The title slot carries the float alignment.
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
// embedThumbW is the width embeds request through the ?w= form of the
// file's own URL, which keeps the <img src> relative.
embedThumbW = 300
)
// embedAlign maps an image title to its float class. A title that is not an
// alignment keyword is ignored and the embed floats right.
func embedAlign(title []byte) string {
switch strings.TrimSpace(string(title)) {
case alignLeft:
return alignLeft
case alignCenter:
return alignCenter
}
return alignRight
}
// embedIsImage reports whether a destination names an image, i.e. whether it
// renders as a <figure> rather than degrading to a link. Extension-only — no
// stat, so a destination gone stale simply 404s when clicked.
func embedIsImage(dest []byte) bool {
return isImageFile(path.Base(string(dest)))
}
type embedRenderer struct{}
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindImage, r.render)
}
// render writes a <figure> holding a thumbnail that links to the full file,
// with the image's inline children as <figcaption>. Children are rendered by
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
// emphasis or a link. A non-image destination renders as a plain anchor
// labelled with the caption, or with the file name when there is none.
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Image)
name := path.Base(string(n.Destination))
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
captioned := n.FirstChild() != nil
if !embedIsImage(n.Destination) {
if entering {
w.WriteString(`<a href="`)
w.Write(dest)
w.WriteString(`">`)
if !captioned {
w.Write(util.EscapeHTML([]byte(name)))
}
} else {
w.WriteString(`</a>`)
}
return ast.WalkContinue, nil
}
if entering {
w.WriteString(`<figure class="embed embed-`)
w.WriteString(embedAlign(n.Title))
w.WriteString(`"><a class="embed-link" href="`)
w.Write(dest)
w.WriteString(`"><img src="`)
w.Write(dest)
w.WriteString(`?w=`)
w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
}
return ast.WalkContinue, nil
}
if captioned {
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
// block level. Goldmark wraps inline content in <p>, but the embed renders a
// <figure> (block), and a <figure> inside a <p> is auto-closed by the browser —
// which strands empty <p> elements (they still carry .content paragraph margins)
// and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>.
type embedBlockTransformer struct{}
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
var paras []*ast.Paragraph
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if p, ok := n.(*ast.Paragraph); ok && t.paragraphFiguresOnly(p, source) {
paras = append(paras, p)
}
}
return ast.WalkContinue, nil
})
for _, p := range paras {
parent := p.Parent()
if parent == nil {
continue
}
// InsertBefore isolates the embed from p first, so hoist each embed to a
// block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
if _, ok := c.(*ast.Image); ok {
parent.InsertBefore(parent, p, c)
}
c = next
}
parent.RemoveChild(parent, p)
}
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
// image and nothing else visible — only such images and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an
// image that degrades to an inline link (non-image destination), stays wrapped
// so the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
case *ast.Image:
if !embedIsImage(n.Destination) {
return false
}
hasFigure = true
case *ast.Text:
if len(bytes.TrimSpace(n.Segment.Value(source))) != 0 {
return false
}
default:
return false
}
}
return hasFigure
}
type embedExt struct{}
// newEmbedExt returns a goldmark extension that renders Markdown images as
// wiki embeds. It adds no parser of its own: goldmark parses the images, and
// the renderer registered at 500 wins over the default HTML renderer
// (registered at 1000 — lowest priority number is applied last and wins).
func newEmbedExt() goldmark.Extender { return &embedExt{} }
func (e *embedExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
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),
))
}
+73 -64
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"html/template" "html/template"
"log" "log"
"math" "math"
@@ -40,8 +41,9 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *
return nil return nil
} }
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m") weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
return &specialPage{ return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel), Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
SuppressTOC: true, SuppressTOC: true,
} }
} }
@@ -173,6 +175,7 @@ func exportWeightUnit(settings json.RawMessage) string {
type weightPoint struct { type weightPoint struct {
date time.Time date time.Time
value float64 value float64
days int // >0: weekly mean over this many measured days
} }
// extractWeights computes the weight series from the export: one point per // extractWeights computes the weight series from the export: one point per
@@ -189,12 +192,43 @@ func extractWeights(ex *wlExport) []weightPoint {
} }
weights := make([]weightPoint, 0, len(byDate)) weights := make([]weightPoint, 0, len(byDate))
for d, v := range byDate { for d, v := range byDate {
weights = append(weights, weightPoint{d, v}) weights = append(weights, weightPoint{date: d, value: v})
} }
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) }) sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights return weights
} }
func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
}
// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday
// start) and averages over the days that have a measurement. Weeks without
// any measurement produce no point.
func weeklyMeanWeights(points []weightPoint) []weightPoint {
type acc struct {
sum float64
n int
}
byWeek := map[time.Time]*acc{}
for _, p := range points {
w := mondayOf(p.date)
a := byWeek[w]
if a == nil {
a = &acc{}
byWeek[w] = a
}
a.sum += p.value
a.n++
}
out := make([]weightPoint, 0, len(byWeek))
for w, a := range byWeek {
out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n})
}
sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) })
return out
}
// === SVG geometry === // === SVG geometry ===
// Chart canvas in viewBox units. The SVG scales to container width via // Chart canvas in viewBox units. The SVG scales to container width via
@@ -235,29 +269,6 @@ func (s chartScale) y(v float64) float64 {
return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
} }
// niceTicks returns round-valued tick positions covering [lo, hi], aiming
// for about n intervals.
func niceTicks(lo, hi float64, n int) []float64 {
span := hi - lo
if span <= 0 || n < 1 {
return nil
}
raw := span / float64(n)
mag := math.Pow(10, math.Floor(math.Log10(raw)))
step := 10 * mag
for _, m := range []float64{1, 2, 5} {
if m*mag >= raw {
step = m * mag
break
}
}
var ticks []float64
for v := math.Ceil(lo/step) * step; v <= hi+step/1e6; v += step {
ticks = append(ticks, math.Round(v*1000)/1000)
}
return ticks
}
// === View models === // === View models ===
type fitnessOptVM struct { type fitnessOptVM struct {
@@ -311,9 +322,11 @@ func newChartVM(title, param, sel string) fitnessChartVM {
} }
} }
func yTickVMs(sc chartScale, n int) []fitnessTickVM { // yTickVMs places gridlines at fixed multiples of step across the y domain
// (always 5 kg guides on weight charts, regardless of range).
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM var out []fitnessTickVM
for _, v := range niceTicks(sc.y0, sc.y1, n) { for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)}) out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
} }
return out return out
@@ -330,8 +343,11 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
// === Chart builders === // === Chart builders ===
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit string, today time.Time) fitnessChartVM { // buildWeightChart renders a weight line chart. It serves both the per-day
vm := newChartVM("Weight ("+unit+")", "weight", sel) // series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement.
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, sel)
points := all points := all
var x0, x1 time.Time var x0, x1 time.Time
@@ -376,45 +392,34 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit s
} }
sc := chartScale{x0, x1, lo - pad, hi + pad} sc := chartScale{x0, x1, lo - pad, hi + pad}
vm.YTicks = yTickVMs(sc, 4) vm.YTicks = yTickVMs(sc, 5)
vm.XTicks = timeXTicks(sc, 4) vm.XTicks = timeXTicks(sc, 4)
// Point markers carry the hover <title>. On dense ranges (1y/All) the // One continuous polyline through every point in range — days without a
// markers are dropped and the bare polyline stays legible; gaps in the // measurement do not break the line. Point markers carry the hover
// data split the line into segments — no interpolation. // <title>; on dense ranges they are dropped and the bare line stays
markAll := len(points) <= 100 // legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM { dot := func(p weightPoint) fitnessDotVM {
return fitnessDotVM{ label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), if p.days > 0 {
Title: p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit, label = fmt.Sprintf("Week of %s: %s %s (%d days)",
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
} }
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
} }
var seg []weightPoint if len(points) >= 2 {
flush := func() { var b strings.Builder
if len(seg) >= 2 { for i, p := range points {
var b strings.Builder if i > 0 {
for i, p := range seg { b.WriteByte(' ')
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(svgNum(sc.x(p.date)))
b.WriteByte(',')
b.WriteString(svgNum(sc.y(p.value)))
} }
vm.Lines = append(vm.Lines, b.String()) b.WriteString(svgNum(sc.x(p.date)))
} else if len(seg) == 1 && !markAll { b.WriteByte(',')
vm.Dots = append(vm.Dots, dot(seg[0])) b.WriteString(svgNum(sc.y(p.value)))
} }
seg = seg[:0] vm.Lines = append(vm.Lines, b.String())
} }
for i, p := range points { if len(points) <= 100 {
if i > 0 && p.date.Sub(points[i-1].date) > 24*time.Hour {
flush()
}
seg = append(seg, p)
}
flush()
if markAll {
for _, p := range points { for _, p := range points {
vm.Dots = append(vm.Dots, dot(p)) vm.Dots = append(vm.Dots, dot(p))
} }
@@ -462,8 +467,8 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM {
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html")) var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath, weightSel string) template.HTML { func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
data := buildFitnessDash(fsPath, weightSel, time.Now()) data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
var buf bytes.Buffer var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil { if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err) log.Printf("fitness template: %v", err)
@@ -475,7 +480,7 @@ func renderFitnessDashboard(fsPath, weightSel string) template.HTML {
// buildFitnessDash reads and parses the export per request — no caching, no // buildFitnessDash reads and parses the export per request — no caching, no
// indexes. A read or parse failure (including a truncated mid-upload file) // indexes. A read or parse failure (including a truncated mid-upload file)
// becomes an inline notice; the page itself always renders. // becomes an inline notice; the page itself always renders.
func buildFitnessDash(fsPath, weightSel string, now time.Time) fitnessDashVM { func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile)) raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
if err != nil { if err != nil {
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."} return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
@@ -488,8 +493,12 @@ func buildFitnessDash(fsPath, weightSel string, now time.Time) fitnessDashVM {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := extractWeights(&ex) weights := extractWeights(&ex)
wGoal, hasWGoal := goalValue(ex.Settings, "weight") wGoal, hasWGoal := goalValue(ex.Settings, "weight")
unit := exportWeightUnit(ex.Settings)
return fitnessDashVM{Charts: []fitnessChartVM{ return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel, exportWeightUnit(ex.Settings), today), buildWeightChart(weights, wGoal, hasWGoal, weightSel,
"weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
"weekly", "Weekly average weight ("+unit+")", unit, today),
}} }}
} }
+147 -54
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"
@@ -36,12 +37,18 @@ func hashAsset(name string) string {
return hex.EncodeToString(sum[:])[:12] return hex.EncodeToString(sum[:])[:12]
} }
// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon,
}
var ( var (
pageTmpl = template.Must(template.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(template.FuncMap{ editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
"editorBundleVersion": func() string { return editorBundleVersion }, searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html")) canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/canvas/main.html"))
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
) )
// specialPage is the result returned by a pageTypeHandler. // specialPage is the result returned by a pageTypeHandler.
@@ -100,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 {
@@ -119,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)
@@ -127,13 +135,19 @@ func main() {
// so the first search after a cold start still returns correct results. // so the first search after a cold start still returns correct results.
go func() { go func() {
folderIndex.buildMu.Lock() folderIndex.buildMu.Lock()
entries := buildFolderIndex(root) folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock() folderIndex.Lock()
folderIndex.entries = entries folderIndex.entries = folders
folderIndex.builtAt = time.Now() folderIndex.builtAt = now
folderIndex.Unlock() folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
folderIndex.buildMu.Unlock() folderIndex.buildMu.Unlock()
close(folderIndex.ready) close(folderIndex.ready)
close(fileIndex.ready)
}() }()
if *reindexInterval > 0 { if *reindexInterval > 0 {
@@ -201,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) {
@@ -209,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)
@@ -221,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"]
@@ -237,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)
@@ -250,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 {
@@ -261,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
@@ -277,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) {
@@ -312,24 +384,24 @@ 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"
} }
parent := ""
if urlPath != "/" {
parent = parentURL(urlPath)
}
data := pageData{ data := pageData{
Title: title, Title: title,
ParentURL: parent,
CanEdit: true, CanEdit: true,
EditMode: editMode, EditMode: editMode,
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,
@@ -340,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")
@@ -355,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")) h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
return return
} }
if query.Has("toggle") { if query.Has("toggle") {
@@ -371,14 +450,6 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleAppend(w, r, urlPath, fsPath) h.handleAppend(w, r, urlPath, fsPath)
return return
} }
if query.Has("cleantasks") {
h.handleCleanTasks(w, r, urlPath, fsPath)
return
}
if query.Has("addtask") {
h.handleAddTask(w, r, urlPath, fsPath)
return
}
if query.Has("settings") { if query.Has("settings") {
h.handleSettings(w, r, urlPath, fsPath) h.handleSettings(w, r, urlPath, fsPath)
return return
@@ -392,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 {
@@ -426,10 +493,25 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
} }
rawMD, _ := os.ReadFile(indexPath) rawMD, _ := os.ReadFile(indexPath)
sections := splitSections(rawMD) sections := splitSections(rawMD)
if sectionIndex < len(sections) { // Out of range means the file changed under the editor (or the index
sections[sectionIndex] = []byte(content) // never matched it). Writing back the untouched file would swallow the
// edit silently, so refuse and keep the editor's content in the browser.
if sectionIndex >= len(sections) {
http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
return
} }
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.
@@ -441,30 +523,41 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
} }
} }
// A save must never remove a page. An empty POST — a truncated mobile
// request, a lost `section` field, an editor that came up blank — is
// indistinguishable from "clear this page", and deleting index.md on that
// signal loses the whole file even though the user only edited one section.
// Removing a page is the explicit ?delete action's job (moves.go).
if strings.TrimSpace(content) == "" { if strings.TrimSpace(content) == "" {
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) { http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest)
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError) return
return }
} // Stat first so we know whether MkdirAll actually created the folder
} else { // — if it did, the search index needs a new entry.
// Stat first so we know whether MkdirAll actually created the folder _, statErr := os.Stat(fsPath)
// — if it did, the search index needs a new entry. newlyCreated := os.IsNotExist(statErr)
_, statErr := os.Stat(fsPath) if err := os.MkdirAll(fsPath, 0755); err != nil {
newlyCreated := os.IsNotExist(statErr) http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
if err := os.MkdirAll(fsPath, 0755); err != nil { return
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) }
return if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
} http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil { return
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) }
return if newlyCreated {
} if rel, err := filepath.Rel(h.root, fsPath); err == nil {
if newlyCreated { folderIndexAdd(filepath.ToSlash(rel))
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
folderIndexAdd(filepath.ToSlash(rel))
}
} }
} }
// The editor saves via fetch so the save and its result share one history
// entry (see assets/history-nav.js). Hand it the target instead of a 303:
// the browser would follow the redirect into a second entry, and fetch
// drops the #section fragment from a followed redirect anyway.
if r.Header.Get("X-Save-Mode") == "replace" {
w.Header().Set("X-Target", redirectTarget)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, redirectTarget, http.StatusSeeOther) http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
} }
+104 -121
View File
@@ -1,20 +1,22 @@
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 // If the destination folder already exists it is normally a hard conflict.
// in memory for rollback. // The one exception is a merge: when the source carries a page (index.md) and
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks bool) { // the destination folder has none, the source's contents can fill the empty
// container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the
// user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, 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)
@@ -40,63 +42,115 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
return return
} }
dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/"))) dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/")))
if _, err := os.Stat(dstFsPath); err == nil {
http.Error(w, "destination already exists", http.StatusConflict)
return
}
// Phase 1: optionally walk the tree and rewrite every index.md that // Decide between a plain rename and a merge into an existing destination.
// references the moved path. Keep the pre-rewrite bytes in memory so we merging := false
// can revert on failure. The walker only reads directory listings and if dstInfo, err := os.Stat(dstFsPath); err == nil {
// files literally named index.md; hidden directories are pruned. A cheap if !canMergeMove(srcFsPath, dstFsPath, dstInfo) {
// substring check skips parsing files that cannot contain a relevant http.Error(w, "destination already exists", http.StatusConflict)
// 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 return
} }
if !merge {
// Ask the client to confirm the merge. The header lets it tell
// this apart from an ordinary conflict.
w.Header().Set("X-Merge-Available", "1")
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
return
}
conflict, err := firstMergeConflict(srcFsPath, dstFsPath)
if err != nil {
http.Error(w, "merge check failed: "+err.Error(), http.StatusInternalServerError)
return
}
if conflict != "" {
http.Error(w, "cannot merge: "+conflict+" exists in both folders", http.StatusConflict)
return
}
merging = true
} }
// Phase 2: create intermediate parent folders for the destination. // Phase 1: create intermediate parent folders for the destination.
if parent := filepath.Dir(dstFsPath); parent != "" { if 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: rename the source folder into place. // Phase 2: move the source into place. A plain move renames the whole
if err := os.Rename(srcFsPath, dstFsPath); err != nil { // folder; a merge moves the source's entries into the existing
rollbackRewrites(rewritten) // destination and drops the emptied source.
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError) if merging {
return if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
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
// merge rather than conflict. Merging is allowed only when the destination is
// a folder with no page of its own (no index.md) and the source has one — so
// the source's page fills the empty destination without overwriting content.
func canMergeMove(srcFsPath, dstFsPath string, dstInfo os.FileInfo) bool {
if !dstInfo.IsDir() {
return false
}
return hasIndexFile(srcFsPath) && !hasIndexFile(dstFsPath)
}
// hasIndexFile reports whether dir contains a regular index.md.
func hasIndexFile(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "index.md"))
return err == nil && info.Mode().IsRegular()
}
// firstMergeConflict returns the name of the first source entry that already
// exists in the destination, or "" when the merge can proceed without
// overwriting anything. index.md cannot collide here: canMergeMove already
// established the destination has none.
func firstMergeConflict(srcFsPath, dstFsPath string) (string, error) {
entries, err := os.ReadDir(srcFsPath)
if err != nil {
return "", err
}
for _, e := range entries {
_, err := os.Lstat(filepath.Join(dstFsPath, e.Name()))
if err == nil {
return e.Name(), nil
}
if !os.IsNotExist(err) {
return "", err
}
}
return "", nil
}
// mergeFolder moves every entry from srcFsPath into dstFsPath, then removes
// the now-empty source. Callers must run firstMergeConflict beforehand so no
// rename overwrites an existing destination entry.
func mergeFolder(srcFsPath, dstFsPath string) error {
entries, err := os.ReadDir(srcFsPath)
if err != nil {
return err
}
for _, e := range entries {
from := filepath.Join(srcFsPath, e.Name())
to := filepath.Join(dstFsPath, e.Name())
if err := os.Rename(from, to); err != nil {
return fmt.Errorf("move %s: %w", e.Name(), err)
}
}
return os.Remove(srcFsPath)
} }
// handleDelete removes the folder at fsPath (URL urlPath) and redirects to // handleDelete removes the folder at fsPath (URL urlPath) and redirects to
@@ -152,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 {
+2 -2
View File
@@ -112,9 +112,9 @@ func formatAppendEntry(title, rawURL, comment string, ts time.Time) string {
b.WriteString(escapeLinkLabel(title)) b.WriteString(escapeLinkLabel(title))
b.WriteString("](") b.WriteString("](")
b.WriteString(rawURL) b.WriteString(rawURL)
b.WriteString(")</br>") b.WriteString(")\n")
b.WriteString(ts.Format("2006-01-02 15:04")) b.WriteString(ts.Format("2006-01-02 15:04"))
b.WriteString("</br>") b.WriteString("\n")
if comment != "" { if comment != "" {
b.WriteString(" ") b.WriteString(" ")
b.WriteString(comment) b.WriteString(comment)
+57 -7
View File
@@ -19,14 +19,14 @@ 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), newExtLinksExt()), goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
goldmark.WithParserOptions(parser.WithAutoHeadingID()), goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe()), goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
) )
} }
@@ -36,6 +36,9 @@ type entry struct {
// ThumbURL is set for thumbnailable files; the thumbnail view renders an // ThumbURL is set for thumbnailable files; the thumbnail view renders an
// <img> when it is non-empty and falls back to Icon otherwise. // <img> when it is non-empty and falls back to Icon otherwise.
ThumbURL string ThumbURL string
// IsVideo marks a thumbnail tile as a video so the grid can overlay a play
// badge. Only meaningful when ThumbURL is set.
IsVideo bool
// modTime/size carry the raw sort keys; the template only reads the // modTime/size carry the raw sort keys; the template only reads the
// formatted Meta string. // formatted Meta string.
modTime time.Time modTime time.Time
@@ -44,12 +47,12 @@ type entry struct {
type pageData struct { type pageData struct {
Title string Title string
ParentURL string
CanEdit bool CanEdit bool
EditMode bool EditMode bool
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
@@ -61,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
@@ -127,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.
@@ -208,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,
@@ -218,6 +258,7 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
} }
if hasThumbnail(name) { if hasThumbnail(name) {
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300) f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
f.IsVideo = isVideoFile(name)
} }
files = append(files, f) files = append(files, f)
} }
@@ -229,6 +270,13 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
sortEntries(folders, sortName, order) sortEntries(folders, sortName, order)
sortEntries(files, sortKey, order) sortEntries(files, sortKey, order)
// Nothing to list (only index.md, which is rendered above): return no
// entries so the Files section is suppressed entirely rather than showing
// an empty listing with just the `..` row.
if len(folders) == 0 && len(files) == 0 {
return nil
}
// `..` row mirrors the header Up button so the listing itself is // `..` row mirrors the header Up button so the listing itself is
// navigable without reaching for the header on mobile. Prepended after // navigable without reaching for the header on mobile. Prepended after
// sort so it always sits at the top regardless of folder names. // sort so it always sits at the top regardless of folder names.
@@ -289,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":
+261 -60
View File
@@ -5,6 +5,7 @@ import (
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"net/url"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -13,32 +14,70 @@ import (
"unicode" "unicode"
) )
// searchSectionCap bounds how many rows each results section renders. The
// section header still reports the true total so the user knows more exist.
const searchSectionCap = 10
// exactNameScore is the score scoreName assigns to a whole-name exact match.
// handleSearch peels these into the promoted "Exact Match" section.
const exactNameScore = 1000
type searchResult struct { type searchResult struct {
Name string Name string
URL string URL string
Path string Path string
Score int Score int
// Meta is the formatted "size · date" line for file results; empty for
// page results.
Meta string
} }
type searchPageData struct { type searchPageData struct {
Title string Title string
ParentURL string EditMode bool
EditMode bool Query string
Query string // Exact holds page(s) whose name equals the query exactly, promoted to
Results []searchResult // their own section above the fuzzy matches.
Exact []searchResult
Pages []searchResult
Files []searchResult
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
// searchSectionCap for rendering.
PageTotal int
FileTotal int
IndexBuiltAt time.Time IndexBuiltAt time.Time
RenderMS int64 RenderMS int64
} }
// folderEntry is a single indexed directory: its forward-slash relative path // ShowPageChrome satisfies the shared layout's contract (see layoutData). The
// plus pre-tokenized basename so the per-query scoring loop avoids redoing // results page is always ordinary chrome — it is never the editor and never a
// the lowercasing and tokenization on every keystroke. // canvas — but EditMode is honoured so the two views cannot drift apart.
type folderEntry struct { func (d searchPageData) ShowPageChrome() bool { return !d.EditMode }
// indexEntry is the shared scoreable core of both folder and file index
// entries: a forward-slash relative path plus its pre-tokenized basename so
// the per-query scoring loop avoids redoing the lowercasing and tokenization
// on every request.
type indexEntry struct {
Path string Path string
NameLower string NameLower string
NameTokens []string NameTokens []string
} }
// folderEntry is a single indexed directory. It is exactly an indexEntry; the
// alias keeps the existing folder-index code readable while letting files and
// folders share the scoring loop.
type folderEntry = indexEntry
// fileEntry is a single indexed file: its scoreable core plus the size/modtime
// captured during the walk so results can show listing-parity metadata without
// a second stat.
type fileEntry struct {
indexEntry
Size int64
ModTime time.Time
}
// folderIndex holds the in-memory directory index used by search. Writers // folderIndex holds the in-memory directory index used by search. Writers
// always replace the entries slice wholesale so a reader that snapshots the // always replace the entries slice wholesale so a reader that snapshots the
// header under RLock can score without holding the lock. // header under RLock can score without holding the lock.
@@ -50,15 +89,38 @@ var folderIndex struct {
ready chan struct{} ready chan struct{}
} }
// fileIndex mirrors folderIndex for files. It is held separately so file
// volume can't perturb the page index, and shares folderIndex.buildMu since
// both are populated by the same single-pass walk. It is refreshed only by the
// full rebuild (startup / ticker / manual), never by the incremental folder
// hooks — file freshness on disk lags until the next rebuild.
var fileIndex struct {
sync.RWMutex
entries []fileEntry
builtAt time.Time
ready chan struct{}
}
func init() { func init() {
folderIndex.ready = make(chan struct{}) folderIndex.ready = make(chan struct{})
fileIndex.ready = make(chan struct{})
} }
// handleSearch renders the search results page for the query in // handleSearch renders the search results page for the query in
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present. // r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) { func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q")) query := strings.TrimSpace(r.URL.Query().Get("q"))
results, builtAt := searchWiki(query) pages, builtAt := searchWiki(query)
files := searchFiles(query)
// searchWiki sorts by score desc, so exact-name matches (score 1000) are
// at the front; peel them into their own section and drop them from the
// fuzzy page list so they aren't shown twice.
var exact []searchResult
for len(pages) > 0 && pages[0].Score == exactNameScore {
exact = append(exact, pages[0])
pages = pages[1:]
}
title := "Search" title := "Search"
if query != "" { if query != "" {
@@ -66,9 +128,12 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
} }
data := searchPageData{ data := searchPageData{
Title: title, Title: title,
ParentURL: "/",
Query: query, Query: query,
Results: results, Exact: exact,
Pages: capResults(pages),
Files: capResults(files),
PageTotal: len(pages),
FileTotal: len(files),
IndexBuiltAt: builtAt, IndexBuiltAt: builtAt,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -78,6 +143,55 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
} }
} }
// capResults truncates a section to searchSectionCap rows for rendering.
func capResults(results []searchResult) []searchResult {
if len(results) > searchSectionCap {
return results[:searchSectionCap]
}
return results
}
// scoredEntry pairs a matched index entry with its score before it is turned
// into a UI searchResult.
type scoredEntry[T any] struct {
entry T
score int
}
// scoreEntries scores every entry against query, drops non-matches, and returns
// the survivors sorted by score (desc), then path depth (asc), then basename
// (asc). core extracts the shared scoreable fields so the folder and file
// indexes reuse one loop. fuzzy toggles the levenshtein fallback (pages yes,
// files no). Returns nil for an empty/tokenless query.
func scoreEntries[T any](entries []T, query string, fuzzy bool, core func(T) indexEntry) []scoredEntry[T] {
qLower := strings.ToLower(query)
qTokens := tokenize(qLower)
if len(qTokens) == 0 {
return nil
}
var out []scoredEntry[T]
for _, e := range entries {
c := core(e)
score := scoreName(c.NameLower, c.NameTokens, qLower, qTokens, fuzzy)
if score == 0 {
continue
}
out = append(out, scoredEntry[T]{entry: e, score: score})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
ci, cj := core(out[i].entry), core(out[j].entry)
di, dj := strings.Count(ci.Path, "/"), strings.Count(cj.Path, "/")
if di != dj {
return di < dj
}
return ci.NameLower < cj.NameLower
})
return out
}
// searchWiki scores the cached folder index against query. Blocks on the // searchWiki scores the cached folder index against query. Blocks on the
// initial build so the very first request after startup serves correct // initial build so the very first request after startup serves correct
// results rather than an empty list. Returns the snapshot's builtAt so the // results rather than an empty list. Returns the snapshot's builtAt so the
@@ -92,45 +206,65 @@ func searchWiki(query string) ([]searchResult, time.Time) {
if query == "" { if query == "" {
return nil, builtAt return nil, builtAt
} }
qLower := strings.ToLower(query) scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e })
qTokens := tokenize(qLower) results := make([]searchResult, 0, len(scored))
if len(qTokens) == 0 { for _, s := range scored {
return nil, builtAt
}
var results []searchResult
for _, e := range entries {
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
if score == 0 {
continue
}
results = append(results, searchResult{ results = append(results, searchResult{
Name: filepath.Base(e.Path), Name: filepath.Base(s.entry.Path),
URL: "/" + e.Path + "/", URL: "/" + s.entry.Path + "/",
Path: e.Path, Path: s.entry.Path,
Score: score, Score: s.score,
}) })
} }
sort.SliceStable(results, func(i, j int) bool {
if results[i].Score != results[j].Score {
return results[i].Score > results[j].Score
}
di, dj := strings.Count(results[i].Path, "/"), strings.Count(results[j].Path, "/")
if di != dj {
return di < dj
}
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
})
return results, builtAt return results, builtAt
} }
// searchFiles scores the cached file index against query, matching on filename
// only. Blocks on the initial build so the first request after startup doesn't
// serve an empty Files section while the walk is still running.
func searchFiles(query string) []searchResult {
<-fileIndex.ready
fileIndex.RLock()
entries := fileIndex.entries
fileIndex.RUnlock()
if query == "" {
return nil
}
scored := scoreEntries(entries, query, false, func(e fileEntry) indexEntry { return e.indexEntry })
results := make([]searchResult, 0, len(scored))
for _, s := range scored {
p := s.entry.Path
results = append(results, searchResult{
Name: filepath.Base(p),
URL: fileURL(p),
Path: p,
Score: s.score,
Meta: formatSize(s.entry.Size) + " · " + s.entry.ModTime.Format("2006-01-02"),
})
}
return results
}
// fileURL builds the browser URL for a file's forward-slash relative path,
// percent-escaping each segment so spaces/umlauts/&c. survive round-tripping
// through the companion's wikiPathFromHref decode.
func fileURL(relPath string) string {
parts := strings.Split(relPath, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return "/" + strings.Join(parts, "/")
}
// scoreName ranks how well nameLower matches the query. Whole-name exact // scoreName ranks how well nameLower matches the query. Whole-name exact
// match dominates; otherwise score is the sum of each token's best match // match dominates; otherwise score is the sum of each token's best match
// against the words in the name. nameTokens is precomputed by the index. // against the words in the name. nameTokens is precomputed by the index. fuzzy
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int { // enables the levenshtein near-match fallback; the file index passes false so
// large file volumes don't pay the edit-distance cost per query.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
if nameLower == qLower { if nameLower == qLower {
return 1000 return exactNameScore
} }
score := 0 score := 0
for _, qt := range qTokens { for _, qt := range qTokens {
@@ -149,7 +283,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 { if best < 20 {
best = 20 best = 20
} }
case levenshtein(w, qt) <= 2: case fuzzy && levenshtein(w, qt) <= 2:
if best < 5 { if best < 5 {
best = 5 best = 5
} }
@@ -216,12 +350,15 @@ func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// buildFolderIndex walks root and returns a fresh slice of folder entries. // buildIndexes walks root once and returns fresh folder and file entries. The
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is // single pass avoids a second full traversal on the ARMv7 NAS. Hidden
// excluded since it cannot be a search match. // directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the
func buildFolderIndex(root string) []folderEntry { // root itself and every `index.md` (page content, not a browsable file) are
// excluded.
func buildIndexes(root string) ([]folderEntry, []fileEntry) {
walkRoot := resolveWalkRoot(root) walkRoot := resolveWalkRoot(root)
var entries []folderEntry var folders []folderEntry
var files []fileEntry
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error { _ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return nil return nil
@@ -229,46 +366,78 @@ func buildFolderIndex(root string) []folderEntry {
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip { if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
return walkErr return walkErr
} }
if !d.IsDir() || fsPath == walkRoot {
return nil
}
rel, relErr := filepath.Rel(walkRoot, fsPath) rel, relErr := filepath.Rel(walkRoot, fsPath)
if relErr != nil { if relErr != nil {
return nil return nil
} }
entries = append(entries, newFolderEntry(filepath.ToSlash(rel))) relSlash := filepath.ToSlash(rel)
if d.IsDir() {
if fsPath != walkRoot {
folders = append(folders, newFolderEntry(relSlash))
}
return nil
}
if d.Name() == "index.md" {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
files = append(files, newFileEntry(relSlash, info.Size(), info.ModTime()))
return nil return nil
}) })
return entries return folders, files
} }
// newFolderEntry builds a folderEntry from a forward-slash relative path, // newFolderEntry builds a folderEntry from a forward-slash relative path,
// computing the lowercased basename and its tokens once so search scoring // computing the lowercased basename and its tokens once so search scoring
// doesn't have to redo it per query. // doesn't have to redo it per query.
func newFolderEntry(relPath string) folderEntry { func newFolderEntry(relPath string) folderEntry {
return newIndexEntry(relPath)
}
// newFileEntry builds a fileEntry, capturing the walk-time size/modtime so
// results show listing-parity metadata without a second stat.
func newFileEntry(relPath string, size int64, modTime time.Time) fileEntry {
return fileEntry{
indexEntry: newIndexEntry(relPath),
Size: size,
ModTime: modTime,
}
}
// newIndexEntry precomputes the lowercased basename and its tokens for the
// per-query scoring loop.
func newIndexEntry(relPath string) indexEntry {
name := relPath name := relPath
if i := strings.LastIndex(relPath, "/"); i >= 0 { if i := strings.LastIndex(relPath, "/"); i >= 0 {
name = relPath[i+1:] name = relPath[i+1:]
} }
nameLower := strings.ToLower(name) nameLower := strings.ToLower(name)
return folderEntry{ return indexEntry{
Path: relPath, Path: relPath,
NameLower: nameLower, NameLower: nameLower,
NameTokens: tokenize(nameLower), NameTokens: tokenize(nameLower),
} }
} }
// rebuildFolderIndex walks root and replaces the index entries atomically. // rebuildFolderIndex walks root once and atomically replaces both the folder
// buildMu serializes overlapping rebuilds (manual + ticker + startup) so // and file indexes. buildMu serializes overlapping rebuilds (manual + ticker +
// the WalkDir cost is paid once even under contention. // startup) so the WalkDir cost is paid once even under contention.
func rebuildFolderIndex(root string) { func rebuildFolderIndex(root string) {
folderIndex.buildMu.Lock() folderIndex.buildMu.Lock()
defer folderIndex.buildMu.Unlock() defer folderIndex.buildMu.Unlock()
entries := buildFolderIndex(root) folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock() folderIndex.Lock()
folderIndex.entries = entries folderIndex.entries = folders
folderIndex.builtAt = time.Now() folderIndex.builtAt = now
folderIndex.Unlock() folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
} }
// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths. // folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
@@ -335,6 +504,38 @@ func folderIndexRenameSubtree(oldRel, newRel string) {
folderIndex.entries = out folderIndex.entries = out
} }
// folderIndexMergeSubtree updates the index after a merge move: it drops the
// source root entry (that folder is gone) and rewrites every descendant's
// prefix to live under newRel. Unlike folderIndexRenameSubtree it does not add
// a newRel entry, since the destination folder already exists in the index.
func folderIndexMergeSubtree(oldRel, newRel string) {
oldRel = strings.Trim(oldRel, "/")
newRel = strings.Trim(newRel, "/")
if oldRel == "" || newRel == "" {
return
}
oldPrefix := oldRel + "/"
folderIndex.Lock()
defer folderIndex.Unlock()
old := folderIndex.entries
out := make([]folderEntry, 0, len(old))
for _, e := range old {
switch {
case e.Path == oldRel:
continue
case strings.HasPrefix(e.Path, oldPrefix):
out = append(out, folderEntry{
Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
NameLower: e.NameLower,
NameTokens: e.NameTokens,
})
default:
out = append(out, e)
}
}
folderIndex.entries = out
}
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree // resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
// even when the configured wiki root is itself a symlink (as on the NAS). // even when the configured wiki root is itself a symlink (as on the NAS).
func resolveWalkRoot(root string) string { func resolveWalkRoot(root string) string {
+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)
}
-199
View File
@@ -7,7 +7,6 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings"
) )
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a // taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
@@ -111,201 +110,3 @@ func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) {
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement) lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
return bytes.Join(lines, []byte("\n")), true return bytes.Join(lines, []byte("\n")), true
} }
// handleCleanTasks rewrites index.md with every completed task line — and its
// continuation lines — removed. Triggered by POST /{path}?cleantasks=1.
func (h *handler) handleCleanTasks(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
indexPath := filepath.Join(fsPath, "index.md")
raw, err := os.ReadFile(indexPath)
if err != nil {
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
updated := stripCompletedTasks(raw)
if !bytes.Equal(updated, raw) {
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
// handleAddTask appends a single `- [ ] text` task to the last task list in
// the selected section, or creates a new list at the end of the section if
// none exists. Triggered by POST /{path}?addtask=<section-index> with form
// field `text`.
func (h *handler) handleAddTask(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
sectionIndex, err := strconv.Atoi(r.URL.Query().Get("addtask"))
if err != nil || sectionIndex < 1 {
http.Error(w, "bad section index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
text := r.FormValue("text")
if strings.ContainsAny(text, "\r\n") {
http.Error(w, "text must be single-line", http.StatusBadRequest)
return
}
text = strings.TrimSpace(text)
if text == "" {
http.Error(w, "empty text", http.StatusBadRequest)
return
}
indexPath := filepath.Join(fsPath, "index.md")
raw, err := os.ReadFile(indexPath)
if err != nil {
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
sections := splitSections(raw)
if sectionIndex >= len(sections) {
http.Error(w, "section out of range", http.StatusBadRequest)
return
}
sections[sectionIndex] = appendToLastTaskList(sections[sectionIndex], text)
updated := joinSections(sections)
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
target := urlPath
ids := headingIDs(updated)
if sectionIndex-1 < len(ids) {
target = urlPath + "#" + ids[sectionIndex-1]
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// splitLines returns raw split on '\n', dropping the trailing empty element
// produced when raw ends in '\n', and reports whether that newline was there.
// reassemble undoes the split with the matching trailing-newline state.
func splitLines(raw []byte) (lines [][]byte, trailingNewline bool) {
trailingNewline = len(raw) > 0 && raw[len(raw)-1] == '\n'
lines = bytes.Split(raw, []byte("\n"))
if trailingNewline && len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
lines = lines[:len(lines)-1]
}
return lines, trailingNewline
}
func reassemble(lines [][]byte, trailingNewline bool) []byte {
out := bytes.Join(lines, []byte("\n"))
if trailingNewline && len(out) > 0 {
out = append(out, '\n')
}
return out
}
// isFence reports whether line opens or closes a fenced code block.
func isFence(line []byte) bool {
t := bytes.TrimLeft(line, " \t")
return bytes.HasPrefix(t, []byte("```")) || bytes.HasPrefix(t, []byte("~~~"))
}
// indentWidth counts leading-whitespace columns, tabs and spaces equally.
// Adequate for the user's own wiki text, where mixed tab/space indents are rare.
func indentWidth(line []byte) int {
n := 0
for n < len(line) && (line[n] == ' ' || line[n] == '\t') {
n++
}
return n
}
// stripCompletedTasks removes every `[x]`/`[X]` task line and its continuation
// lines (blank, or indented strictly more than the bullet) from raw. Lines
// inside fenced code blocks are ignored, matching flipTaskLine's contract.
func stripCompletedTasks(raw []byte) []byte {
lines, trailing := splitLines(raw)
out := make([][]byte, 0, len(lines))
inFence := false
for i := 0; i < len(lines); i++ {
line := lines[i]
if isFence(line) {
inFence = !inFence
out = append(out, line)
continue
}
if !inFence {
if m := taskLineRe.FindSubmatch(line); m != nil && (m[2][0] == 'x' || m[2][0] == 'X') {
bulletIndent := indentWidth(line)
j := i + 1
for j < len(lines) {
next := lines[j]
if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= bulletIndent {
break
}
j++
}
i = j - 1
continue
}
}
out = append(out, line)
}
return reassemble(out, trailing)
}
// appendToLastTaskList inserts `- [ ] text` after the last task list item in
// sectionBytes. If no task list exists in the section, it appends a new list
// at the end, separated by a blank line. Bullet character and indent are
// inherited from the existing last item when present.
func appendToLastTaskList(sectionBytes []byte, text string) []byte {
lines, trailing := splitLines(sectionBytes)
// Forward scan: track fence state and remember the last non-fenced task line.
lastTask, lastPrefix, lastIndent := -1, "", 0
inFence := false
for i, line := range lines {
if isFence(line) {
inFence = !inFence
continue
}
if inFence {
continue
}
if m := taskLineRe.FindSubmatch(line); m != nil {
lastTask = i
lastPrefix = string(m[1])
lastIndent = indentWidth(line)
}
}
if lastTask >= 0 {
// Walk forward over continuation lines (blank or more-indented), then
// back over trailing blanks so the new task slots in after the last
// real content line of the item.
end := lastTask + 1
for end < len(lines) {
next := lines[end]
if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= lastIndent {
break
}
end++
}
for end > lastTask+1 && len(bytes.TrimSpace(lines[end-1])) == 0 {
end--
}
newLine := []byte(lastPrefix + "[ ] " + text)
out := append(append(append([][]byte{}, lines[:end]...), newLine), lines[end:]...)
return reassemble(out, trailing)
}
// No task list — append one at section end, blank-line-separated from any
// preceding content. Trim trailing blanks first to control spacing exactly.
for len(lines) > 0 && len(bytes.TrimSpace(lines[len(lines)-1])) == 0 {
lines = lines[:len(lines)-1]
}
if len(lines) > 0 {
lines = append(lines, nil)
}
lines = append(lines, []byte("- [ ] "+text))
return reassemble(lines, trailing)
}
+62 -24
View File
@@ -26,6 +26,14 @@ type Thumbnailer interface {
Generate(src io.Reader, dst io.Writer, width int) error Generate(src io.Reader, dst io.Writer, width int) error
} }
// PathThumbnailer is an optional capability for thumbnailers whose source must
// be a seekable file on disk rather than a stream (e.g. ffmpeg for video). When
// a Thumbnailer also implements this, handleThumb passes the file path directly
// and skips reading the file into memory and content-hashing it.
type PathThumbnailer interface {
GenerateFromPath(srcPath string, dst io.Writer, width int) error
}
var thumbnailers []Thumbnailer var thumbnailers []Thumbnailer
// thumbCacheDir is set from the -cache flag at startup. // thumbCacheDir is set from the -cache flag at startup.
@@ -88,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)
@@ -116,11 +129,39 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
} }
} }
digest, data, err := sourceDigest(srcFS, srcInfo) // Path thumbnailers (video/ffmpeg) take the source path directly and key
if err != nil { // the cache on path+mtime+size, so the file is never read into memory.
log.Printf("thumb digest %s: %v", rel, err) // Content thumbnailers (images) stay content-addressed so renames reuse
http.Error(w, "thumbnail failed", http.StatusInternalServerError) // the cache entry. Either way, generation runs through the closure below.
return var (
digest string
write func(dst io.Writer) error
)
if pt, ok := t.(PathThumbnailer); ok {
digest = pathDigest(srcFS, srcInfo)
write = func(dst io.Writer) error { return pt.GenerateFromPath(srcFS, dst, width) }
} else {
d, data, err := sourceDigest(srcFS, srcInfo)
if err != nil {
log.Printf("thumb digest %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
digest = d
write = func(dst io.Writer) error {
var src io.Reader
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
return err
}
defer f.Close()
src = f
}
return t.Generate(src, dst, width)
}
} }
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width)) cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
@@ -138,21 +179,7 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
return return
} }
var src io.Reader if err := generateThumb(cacheFS, write); err != nil {
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
log.Printf("thumb open %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
defer f.Close()
src = f
}
if err := generateThumb(t, src, cacheFS, width); err != nil {
log.Printf("thumb %s: %v", rel, err) log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError) http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return return
@@ -160,6 +187,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
serveThumb(w, r, cacheFS) serveThumb(w, r, cacheFS)
} }
// pathDigest keys the cache for path-based thumbnailers on the source path plus
// its mtime and size, so the (potentially large) file is never read into memory
// just to hash it. Overwriting the file changes mtime/size and busts the entry.
func pathDigest(srcFS string, info os.FileInfo) string {
sum := sha256.Sum256(fmt.Appendf(nil, "%s\x00%d\x00%d", srcFS, info.ModTime().UnixNano(), info.Size()))
return hex.EncodeToString(sum[:])
}
// sourceDigest returns the SHA-256 hex digest of a source file's content. // sourceDigest returns the SHA-256 hex digest of a source file's content.
// On a cache hit (path + mtime + size unchanged) the returned data is nil, // On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read // so the caller knows to open the file itself. On a miss the file is read
@@ -191,7 +226,10 @@ func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
http.ServeFile(w, r, cacheFS) http.ServeFile(w, r, cacheFS)
} }
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) error { // generateThumb writes a thumbnail to cacheFS atomically: write builds the
// image into a temp file in the same dir, which is renamed into place only on
// success so readers never see a partial file.
func generateThumb(cacheFS string, write func(dst io.Writer) error) error {
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
return err return err
} }
@@ -200,7 +238,7 @@ func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) erro
return err return err
} }
tmpName := tmp.Name() tmpName := tmp.Name()
if err := t.Generate(src, tmp, width); err != nil { if err := write(tmp); err != nil {
tmp.Close() tmp.Close()
os.Remove(tmpName) os.Remove(tmpName)
return err return err
+10
View File
@@ -7,12 +7,22 @@ import (
"image/jpeg" "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"path/filepath"
"strings"
) )
func init() { func init() {
thumbnailers = append(thumbnailers, &imageThumbnailer{}) thumbnailers = append(thumbnailers, &imageThumbnailer{})
} }
// isImageFile reports whether name is a raster image we can thumbnail and thus
// embed on a page. Video files also carry thumbnails but are not embeddable, so
// this keys on the image thumbnailer's own extension set rather than
// hasThumbnail.
func isImageFile(name string) bool {
return (&imageThumbnailer{}).CanHandle(strings.ToLower(filepath.Ext(name)))
}
type imageThumbnailer struct{} type imageThumbnailer struct{}
func (it *imageThumbnailer) CanHandle(ext string) bool { func (it *imageThumbnailer) CanHandle(ext string) bool {
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func init() {
thumbnailers = append(thumbnailers, &videoThumbnailer{})
}
// ffmpegPath is resolved once at startup. Empty means ffmpeg is not installed,
// in which case the video thumbnailer disables itself and videos keep their
// icon. ffmpeg is a graceful soft-dependency, not a hard requirement: the
// binary still runs standalone without it.
var ffmpegPath, _ = exec.LookPath("ffmpeg")
type videoThumbnailer struct{}
func isVideoExt(ext string) bool {
switch ext {
case ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v":
return true
}
return false
}
// isVideoFile reports whether name is a video container we generate previews
// for. Independent of ffmpeg availability — used to tag tiles with a play badge.
func isVideoFile(name string) bool {
return isVideoExt(strings.ToLower(filepath.Ext(name)))
}
func (vt *videoThumbnailer) CanHandle(ext string) bool {
return ffmpegPath != "" && isVideoExt(ext)
}
// Generate satisfies Thumbnailer but is never used: handleThumb routes video
// through the PathThumbnailer branch (GenerateFromPath), since ffmpeg needs a
// seekable file path and we must not read large videos into memory.
func (vt *videoThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
return fmt.Errorf("video thumbnails require a file path; use GenerateFromPath")
}
// GenerateFromPath extracts a single frame and writes it to dst as JPEG. It
// prefers a frame from the middle of the clip (see seekOffsets) to avoid the
// black/fade-in frames common at the very start, falling back to earlier
// offsets. The (small) JPEG is buffered first so a failed attempt never leaves
// partial bytes in dst.
func (vt *videoThumbnailer) GenerateFromPath(srcPath string, dst io.Writer, width int) error {
for _, seek := range seekOffsets(srcPath) {
var buf bytes.Buffer
if err := runFFmpegFrame(srcPath, &buf, width, seek); err == nil && buf.Len() > 0 {
_, err = dst.Write(buf.Bytes())
return err
}
}
return fmt.Errorf("ffmpeg produced no frame for %s", srcPath)
}
// seekOffsets returns seek targets (in seconds) to try in order. When the clip
// duration is known the midpoint comes first — a representative frame that
// dodges black intros and end credits. "1" and "0" are fallbacks for when the
// duration is unknown or the midpoint seek yields nothing (very short clips, or
// a keyframe gap at the midpoint).
func seekOffsets(srcPath string) []string {
if d := videoDurationSec(srcPath); d > 2 {
return []string{strconv.FormatFloat(d/2, 'f', 2, 64), "1", "0"}
}
return []string{"1", "0"}
}
// videoDurationSec parses the clip duration from ffmpeg's own stderr, so it
// depends only on ffmpeg (ffprobe is not installed everywhere we deploy).
// `ffmpeg -i <file>` with no output exits non-zero but prints the container
// metadata, including a "Duration: HH:MM:SS.ss" line. Returns 0 when the
// duration can't be determined, so the caller falls back to a fixed offset.
func videoDurationSec(srcPath string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath, "-i", srcPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
_ = cmd.Run() // expected to "fail": no output file specified. We want stderr.
return parseFFmpegDuration(stderr.String())
}
// parseFFmpegDuration extracts seconds from an ffmpeg log containing a line like
// " Duration: 00:01:23.45, start: 0.000000, bitrate: 1234 kb/s".
func parseFFmpegDuration(log string) float64 {
_, after, ok := strings.Cut(log, "Duration:")
if !ok {
return 0
}
field := strings.TrimSpace(after)
if c := strings.IndexByte(field, ','); c >= 0 {
field = field[:c]
}
parts := strings.Split(strings.TrimSpace(field), ":")
if len(parts) != 3 {
return 0
}
h, err1 := strconv.ParseFloat(parts[0], 64)
m, err2 := strconv.ParseFloat(parts[1], 64)
s, err3 := strconv.ParseFloat(parts[2], 64)
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
return h*3600 + m*60 + s
}
func runFFmpegFrame(srcPath string, dst io.Writer, width int, seek string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath,
"-ss", seek,
"-i", srcPath,
"-frames:v", "1",
// Cap width at the requested size but never upscale (matches the image
// thumbnailer); -2 keeps the height even. The comma in min() is escaped
// so ffmpeg does not read it as a filter separator.
"-vf", "scale='min(iw\\,"+strconv.Itoa(width)+")':-2",
"-f", "mjpeg",
"-q:v", "5",
"pipe:1",
)
cmd.Stdout = dst
return cmd.Run()
}
+50 -4
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os" "os"
"path/filepath"
"sort" "sort"
"strings" "strings"
) )
@@ -11,6 +12,9 @@ import (
type treeEntry struct { type treeEntry struct {
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"` Kind string `json:"kind"`
// Children is populated only along the expandTo chain (see handleTree);
// omitted otherwise so the flat picker listing keeps its original shape.
Children []treeEntry `json:"children,omitempty"`
} }
type treeResponse struct { type treeResponse struct {
@@ -36,12 +40,18 @@ 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
} }
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
}
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries} resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
@@ -58,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
@@ -73,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"})
@@ -83,3 +93,39 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
return append(folders, files...), nil return append(folders, files...), nil
} }
// treePathSegments splits a wiki path into its non-empty segments.
func treePathSegments(p string) []string {
var segs []string
for _, s := range strings.Split(p, "/") {
if s != "" {
segs = append(segs, s)
}
}
return segs
}
// expandTreeChain walks segs, matching each against a folder in entries by
// name, loading that folder's children in place, and recursing. The walk stops
// at the deepest matching segment, so a stale or deleted path simply expands as
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 {
return
}
for i := range entries {
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return
}
}
+55 -100
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,59 +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()
}
// 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)
@@ -148,32 +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)
target := string(n.Target) writeWikiSearchLink(w, string(n.Query), string(n.Display))
href := wikiTargetHref(target)
display := string(n.Display)
if display == "" {
display = wikiDefaultDisplay(target)
}
broken := !wikiTargetExists(r.root, target)
w.WriteString(`<a href="`)
w.WriteString(href)
w.WriteString(`"`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
type wikiLinkExt struct{ root string } // writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
// search page, labelled with the display override or the query itself. Nothing
// is resolved at render time — whether the query matches anything is the search
// page's business, so there is no broken state here.
func writeWikiSearchLink(w util.BufWriter, query, display string) {
if display == "" {
display = query
}
w.WriteString(`<a href="`)
w.WriteString(wikiSearchHref(query))
w.WriteString(`">`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
}
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) {
@@ -183,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),
)) ))
} }