Compare commits

..
5 Commits
Author SHA1 Message Date
luxick c456e35c76 Fix section linking and editing 2026-10-04 16:01:47 +02:00
luxick 06da56d0a0 Canvas V2 2026-10-04 15:58:27 +02:00
luxick 95002a34ad Minor canvas improvements 2026-10-04 15:34:47 +02:00
luxick 2903986f44 Canvas support V1 2026-10-04 15:28:06 +02:00
luxick 7749d7c237 Use HTMX fragements on the fitness dashboard 2026-10-02 17:42:29 +02:00
26 changed files with 4083 additions and 507 deletions
+24 -1
View File
@@ -11,6 +11,7 @@ Minimal self-hosted personal wiki. Folders are pages.
- **Search**: find pages by name.
- **Wikilinks**: `[[query]]` or `[[query::label]]`. They open a search, so links still work after a page is renamed. Typing `[[` in the editor suggests page names. Embed files with `![caption](photo.jpg "left")`.
- **Movie import**: create a page from OMDb metadata. Asks once for a free [OMDb API key](https://www.omdbapi.com/apikey.aspx) and keeps it in the browser.
- **Canvases**: [JSON Canvas](https://jsoncanvas.org) boards stored next to a page. See [Canvas](#canvas).
- **Special folders**: a [photo diary](#diary) and a [fitness dashboard](#fitness).
- **Calendars**: Nextcloud calendars mirrored into the wiki. They add events and weight to the diary, and a [maintenance widget](#calendars) to every page.
- **Quick-add bookmarklet**: save the current tab to a wiki page.
@@ -54,9 +55,31 @@ All use Alt+Shift.
| Where | Keys |
|-------|------|
| Anywhere | `E` edit, `N` new page, `M` move, `F` search, `H` maintenance |
| Anywhere | `E` edit (on a canvas: the selected node), `N` new page, `M` move, `F` search, `H` maintenance |
| Editor | `S` save, `1`–`3` heading, `B`/`I` bold/italic, `C`/`K` code/code block, `L` link, `P` wikilink, `G` embed, `U`/`O`/`X` bullet/numbered/task list, `Q` quote, `R` rule, `T` format table, `D`/`W` date (ISO/long), `V` movie, `Y` delete line |
## Canvas
A page can hold any number of canvases: `<name>.canvas` files in its folder, in the [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) format. **CANVAS** in the page actions creates one. Canvases show up in the page's file list. Opening one shows the whole canvas, with no folder tree or sidebar. `?raw` on its URL returns the file itself.
- **Notes** (text nodes) are markdown, rendered like pages: wikilinks, embeds, tables, task checkboxes. Double-click a note to edit it with the page editor, including its toolbar and shortcuts. Esc or a click outside closes the editor. A note left empty is removed.
- **Files** show images, videos and audio. A markdown file or a page (a folder) is shown rendered, and a `#Heading` subpath narrows it to that section. Anything else shows as a link. Paths are relative to the wiki root.
- **Links** show as a card. **Groups** are labelled frames: moving a group moves what is inside it.
- **Connections**: drag from a node's side handle onto another node. Dropping on empty space creates a new note there. Double-click a connection to label it.
| Input | |
|-------|-|
| Drag background / Space+drag / middle drag | Pan |
| Wheel, pinch | Zoom (Shift+wheel pans sideways) |
| Shift+drag background | Select an area |
| Double-click background | New note |
| Double-click node, Enter, Alt+Shift+E | Edit (label, URL or file for non-notes) |
| Drag node / corner handle | Move / resize, snapped to a 10 px grid (Alt: no snap) |
| Arrows | Nudge (Shift: 1 px) |
| Del, Ctrl+Z, Ctrl+Y, Ctrl+D, Ctrl+A | Delete, undo, redo, duplicate, select all |
Changes save automatically. If the file changes elsewhere while a canvas is open, saving stops and asks for a reload instead of overwriting.
## Diary
```
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{{/* The canvas view (canvas.go). It fills the whole shell: no folder tree,
no sidebar, no footer. Links are not boosted, so leaving the canvas is a full
navigation and canvas.js can save on pagehide. */}}
{{define "boost"}} hx-boost="false"{{end}}
{{define "page-actions"}}<div id="page-actions" class="page-actions">{{template "headerActions" .}}</div>{{end}}
{{define "headerActions"}}
<a class="btn btn-block" href="{{.PageURL}}" title="Back to the page"><span class="btn-icon">{{icon "up"}}</span><span class="btn-label">PAGE</span></a>
<a class="btn btn-block" href="{{.RawURL}}" download="{{.Name}}" title="Download the canvas file"><span class="btn-icon">{{icon "doc"}}</span><span class="btn-label">DOWNLOAD</span></a>
{{end}}
{{define "shell"}}
<div class="canvas-app" data-version="{{.Version}}"{{if .Error}} data-readonly{{end}}>
{{if .Error}}<p class="canvas-error">This canvas cannot be edited: {{.Error}}</p>{{end}}
<div class="canvas-stage">
<div class="canvas-viewport" tabindex="0">
<div class="canvas-world">
<svg class="canvas-edges" aria-hidden="true"></svg>
<div class="canvas-edge-labels"></div>
<div class="canvas-nodes">{{range .Nodes}}{{template "canvas-node" .}}{{end}}</div>
</div>
<p class="canvas-hint"{{if .Nodes}} hidden{{end}}>Double-click to add a note. Right-click or long-press for more.</p>
</div>
<div class="canvas-statusbar muted"><span class="canvas-status" aria-live="polite"></span><span class="canvas-zoom"></span></div>
{{if not .Error}}<div class="canvas-editor-toolbar" hidden>{{template "editor-toolbar"}}</div>{{end}}
</div>
<script type="application/json" id="canvas-data">{{.Doc}}</script>
</div>
{{if not .Error}}
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
<script src="{{asset "editor/tables.js"}}"></script>
<script src="{{asset "editor/dates.js"}}"></script>
<script src="{{asset "editor/movie.js"}}"></script>
<script src="{{asset "editor/wikicomplete.js"}}"></script>
<script src="{{asset "editor/core.js"}}"></script>
{{end}}
<script src="{{asset "canvas/canvas.js"}}"></script>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{/* One canvas node element (canvas.go canvasNodeView). Rendered for the
whole canvas on load and for a single node by POST ?render, so the editor
(canvas/canvas.js) never builds node markup itself. Geometry is in canvas
units; canvas.js keeps style and data-color in sync while editing. */}}
{{define "canvas-node"}}<div class="canvas-node canvas-node-{{.Type}}" data-id="{{.ID}}"{{with .Preset}} data-color="{{.}}"{{end}} style="left: {{.X}}px; top: {{.Y}}px; width: {{.W}}px; height: {{.H}}px;{{with .Hex}} --node-color: {{.}};{{end}}">
{{- if .Label}}<div class="canvas-node-label truncate">{{if .LabelURL}}<a href="{{.LabelURL}}">{{.Label}}</a>{{else}}{{.Label}}{{end}}</div>{{end -}}
<div class="canvas-node-body{{if .Markdown}} content{{end}}">{{.Body}}</div></div>{{end}}
{{define "canvas-media"}}{{if eq .Kind "image"}}<a class="canvas-media" href="{{.URL}}"><img src="{{.Thumb}}" alt="" loading="lazy" draggable="false"></a>
{{- else if eq .Kind "video"}}<video class="canvas-media" src="{{.URL}}" controls preload="metadata"></video>
{{- else}}<audio class="canvas-media" src="{{.URL}}" controls preload="metadata"></audio>{{end}}{{end}}
{{define "canvas-file"}}<a class="canvas-card" href="{{.URL}}"><span class="canvas-card-icon">{{.Icon}}</span><span class="truncate">{{.Name}}</span></a>{{end}}
{{define "canvas-link"}}<a class="canvas-card" href="{{.URL}}" target="_blank" rel="noopener noreferrer" title="{{.URL}}"><img class="canvas-card-icon" src="https://icons.duckduckgo.com/ip3/{{.Host}}.ico" alt="" width="16" height="16" draggable="false"><span class="canvas-card-text">{{if .Label}}<span class="truncate">{{.Label}}</span><span class="muted truncate">{{.Host}}</span>{{else}}<span class="truncate">{{.Host}}</span><span class="muted truncate">{{.URL}}</span>{{end}}</span></a>{{end}}
{{define "canvas-missing"}}<p class="canvas-missing">Missing: {{.}}</p>{{end}}
{{define "canvas-group-bg"}}<div class="canvas-group-bg canvas-group-bg-{{.Style}}" style="background-image: url('{{.URL}}')"></div>{{end}}
+2
View File
@@ -132,6 +132,8 @@
// Folders end with "/" — let the browser navigate normally.
var path = (item && item.dataset.path) || anchor.getAttribute('href');
if (!path || path.endsWith('/')) return;
// Canvases open in the wiki's own canvas view.
if (/\.canvas$/i.test(path.split(/[?#]/)[0])) return;
// Allow modified clicks (open in new tab, etc.) to pass through.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var rel = wikiPathFromHref(path);
+434
View File
@@ -0,0 +1,434 @@
// core.js — the markdown editor: the CodeMirror setup plus the formatting
// actions behind the toolbar (editor/toolbar.html) and the Alt+Shift
// shortcuts. Shared by the page editor (main.js) and the canvas node editor
// (canvas/canvas.js).
//
// MarkdownEditor.create(mount, opts) → { view, actions, getValue, focus, destroy }
// opts.doc initial text
// opts.keymap extra CM key bindings, ahead of the defaults
// opts.onChange called after every change to the text
// MarkdownEditor.bind(root, getActions)
// wires root's [data-action] buttons, [data-key] shortcuts and toolbar
// dropdowns to whatever getActions() returns (null = no editor open).
window.MarkdownEditor = (function () {
var T = EditorTables;
var D = EditorDates;
var M = EditorMovie;
// 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');
}
// pageBase is the folder of the page being edited, slash-terminated: the
// page URL itself, or for a canvas (/Page/Board.canvas) the folder it
// lives in. Relative embed destinations resolve against it.
function pageBase() {
var p = decodeURIComponent(window.location.pathname);
return p.slice(0, p.lastIndexOf('/') + 1);
}
function create(mount, opts) {
opts = opts || {};
var doc = opts.doc || '';
var view;
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
// highest precedence so they win over CM's default newline/forward-delete.
// Returning false (no table at cursor) lets CM fall back to its default.
function tableKey(fn) {
return function (v) {
var result = fn(v.state.doc.toString(), v.state.selection.main.head);
if (!result) return false;
dispatchFullReplace(result);
return true;
};
}
function tableNavKey(fn) {
var handler = tableKey(fn);
return function (v) {
if (completionOpen()) return false;
return handler(v);
};
}
var tableKeymap = [
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
{ 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({
doc: doc,
// 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: doc.length },
extensions: [
// The caller's keys go first, ahead of even autocompletion's
// own highest-precedence keys: those swallow an Escape while a
// completion query is still pending, with no popup in sight.
CM.Prec.highest(CM.keymap.of(opts.keymap || [])),
CM.history(),
CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
// Enable native browser spellcheck on the contenteditable surface
// (CM6 leaves it off by default). autocapitalize helps prose entry
// on the Android/mobile path; CM's DOM observer absorbs corrections.
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,
opts.onChange ? CM.EditorView.updateListener.of(function (u) {
if (u.docChanged) opts.onChange();
}) : [],
CM.Prec.highest(CM.keymap.of(tableKeymap)),
CM.keymap.of([].concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab]
)),
],
});
view = new CM.EditorView({ state: state, parent: mount });
view.focus();
// --- CM document helpers ---
function dispatchFullReplace(result) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: result.text },
selection: { anchor: result.cursor },
scrollIntoView: true,
});
view.focus();
}
function insertAtCursor(s) {
var sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: s },
selection: { anchor: sel.from + s.length },
scrollIntoView: true,
});
view.focus();
}
function wrap(before, after, placeholder) {
var sel = view.state.selection.main;
var hadSelection = sel.to > sel.from;
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
var insert = before + selected + after;
var anchor, head;
if (hadSelection) {
anchor = head = sel.from + insert.length;
} else {
anchor = sel.from + before.length;
head = anchor + placeholder.length;
}
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: insert },
selection: { anchor: anchor, head: head },
scrollIntoView: true,
});
view.focus();
}
function linePrefix(prefix) {
var sel = view.state.selection.main;
var line = view.state.doc.lineAt(sel.from);
view.dispatch({
changes: { from: line.from, to: line.from, insert: prefix },
selection: { anchor: sel.from + prefix.length },
scrollIntoView: true,
});
view.focus();
}
function applyTableOp(fn, arg) {
var text = view.state.doc.toString();
var pos = view.state.selection.main.head;
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
if (result) dispatchFullReplace(result);
}
// Adapter passed to movie.js so it reads/writes the CM document instead of a
// textarea (replace() dispatches a transaction; cursor lands after the block).
var movieCtx = {
getValue: function () { return view.state.doc.toString(); },
replace: function (start, end, text) {
view.dispatch({
changes: { from: start, to: end, insert: text },
selection: { anchor: start + text.length },
scrollIntoView: true,
});
view.focus();
},
};
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
// plus an optional display-text field, inserting [[query]] or
// [[query::display]] at the cursor. Picking a suggestion only fills in that
// page's name — the link resolves through search at click time, so a path
// never enters the document. Inline `[[` typing uses the same suggestions
// via wikicomplete.js.
function insertWikilink() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var container = document.createElement('div');
var queryWrap = document.createElement('div');
var queryInput = document.createElement('input');
queryInput.type = 'text';
queryInput.className = 'input';
queryInput.placeholder = 'Search query';
// A selection is the query, not the label: [[selection]] renders with
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)';
container.appendChild(queryWrap);
container.appendChild(displayInput);
var handle = openModal({
title: 'Insert link',
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: !selectedText,
onConfirm: function () {
var query = queryInput.value.trim();
if (!query) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
}
}
});
function updateConfirm() {
handle.setConfirmDisabled(!queryInput.value.trim());
}
queryInput.addEventListener('input', updateConfirm);
window.attachSuggestions(queryInput, {
showFooter: false,
container: queryWrap,
onPick: function (r) {
queryInput.value = r.name;
updateConfirm();
displayInput.focus();
}
});
}
// 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 = pageBase();
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 = pageBase().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
));
}
}
});
}
});
}
var actions = {
undo: function () { CM.undo(view); view.focus(); },
redo: function () { CM.redo(view); view.focus(); },
deleteline: function () { CM.deleteLine(view); view.focus(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
embed: insertEmbed,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
fmttable: function () { applyTableOp(T.formatTableText); },
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
tblinsertcol: function () { applyTableOp(T.insertColumn); },
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
tblinsertrow: function () { applyTableOp(T.insertRow); },
tbldeleterow: function () { applyTableOp(T.deleteRow); },
dateiso: function () { insertAtCursor(D.isoDate()); },
datelong: function () { insertAtCursor(D.longDate()); },
movie: function () { M.run(movieCtx); },
};
return {
view: view,
actions: actions,
getValue: function () { return view.state.doc.toString(); },
focus: function () { view.focus(); },
destroy: function () { view.destroy(); },
};
}
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
// menu to the viewport so it escapes the clip, opening upward when there
// isn't room below it (the bottom-toolbar case).
function pinMenu(toggle, menu) {
if (!menu.classList.contains('is-open')) return;
var r = toggle.getBoundingClientRect();
var vh = window.innerHeight;
menu.style.position = 'fixed';
menu.style.overflowY = 'auto';
var spaceBelow = vh - r.bottom;
var spaceAbove = r.top;
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
menu.style.top = 'auto';
menu.style.bottom = (vh - r.top) + 'px';
menu.style.maxHeight = (spaceAbove - 8) + 'px';
} else {
menu.style.bottom = 'auto';
menu.style.top = r.bottom + 'px';
menu.style.maxHeight = (spaceBelow - 8) + 'px';
}
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
menu.style.left = Math.max(4, left) + 'px';
}
function bind(root, getActions) {
function run(name) {
var actions = getActions();
var action = actions && actions[name];
if (action) action();
}
var keyMap = {};
root.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () { run(btn.dataset.action); });
if (btn.dataset.key) keyMap[btn.dataset.key] = btn.dataset.action;
});
// Keep the editor focused when a toolbar button is tapped. Without this the
// button steals focus on mousedown, which dismisses the mobile soft keyboard
// before the action runs (and view.focus() can't reopen it without a direct
// gesture). preventDefault on mousedown blocks the focus shift; click still
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
// dropdown toggles, which also must not pull focus off the editor.
var toolbar = root.classList.contains('editor-toolbar') ? root : root.querySelector('.editor-toolbar');
if (toolbar) {
toolbar.addEventListener('mousedown', function (e) {
if (e.target.closest('.btn')) e.preventDefault();
});
}
document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey || !getActions()) return;
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
// on US, "!" on DE), so fall back to e.code for digit rows.
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
var name = keyMap[key];
if (name) {
e.preventDefault();
run(name);
}
});
root.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
wireDropdown(toggle);
var menu = toggle.parentElement.querySelector('.dropdown-menu');
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
// Runs after wireDropdown's own click handler has toggled is-open.
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
});
}
return { create: create, bind: bind };
})();
+2 -64
View File
@@ -11,70 +11,7 @@
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
{{if .Span}}<input type="hidden" name="span" value="1">{{end}}
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
<div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
<button type="button" class="btn btn-tool btn-block" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
<button type="button" class="btn btn-tool btn-block" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
</div>
</span>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="task" data-key="X" title="Task list (X)">Task list</button>
</div>
</span>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignleft" title="Align left">Align left</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblaligncenter" title="Align center">Align center</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignright" title="Align right">Align right</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertcol" title="Insert column">Insert column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeletecol" title="Delete column">Delete column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertrow" title="Insert row">Insert row</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeleterow" title="Delete row">Delete row</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
<button type="button" class="btn btn-tool btn-block" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
</div>
</span>
</div>
{{template "editor-toolbar"}}
<div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form>
@@ -83,5 +20,6 @@
<script src="{{asset "editor/dates.js"}}"></script>
<script src="{{asset "editor/movie.js"}}"></script>
<script src="{{asset "editor/wikicomplete.js"}}"></script>
<script src="{{asset "editor/core.js"}}"></script>
<script src="{{asset "editor/main.js"}}"></script>
{{end}}
+7 -394
View File
@@ -1,161 +1,12 @@
// The page editor: one markdown editor (core.js) inside the edit form, saved
// with the header's SAVE button.
(function () {
var mount = document.getElementById('editor');
var hidden = document.getElementById('editor-content');
if (!mount || !hidden || !window.CM) return;
var form = hidden.closest('form');
var T = EditorTables;
var D = EditorDates;
var M = EditorMovie;
// --- CodeMirror setup ---
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
// highest precedence so they win over CM's default newline/forward-delete.
// Returning false (no table at cursor) lets CM fall back to its default.
function tableKey(fn) {
return function (view) {
var result = fn(view.state.doc.toString(), view.state.selection.main.head);
if (!result) return false;
dispatchFullReplace(result);
return true;
};
}
// 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 = [
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
{ 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({
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: [
CM.history(),
CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
// Enable native browser spellcheck on the contenteditable surface
// (CM6 leaves it off by default). autocapitalize helps prose entry
// on the Android/mobile path; CM's DOM observer absorbs corrections.
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,
CM.Prec.highest(CM.keymap.of(tableKeymap)),
CM.keymap.of([].concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab]
)),
],
});
var view = new CM.EditorView({ state: state, parent: mount });
view.focus();
// --- CM document helpers ---
function dispatchFullReplace(result) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: result.text },
selection: { anchor: result.cursor },
scrollIntoView: true,
});
view.focus();
}
function insertAtCursor(s) {
var sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: s },
selection: { anchor: sel.from + s.length },
scrollIntoView: true,
});
view.focus();
}
function wrap(before, after, placeholder) {
var sel = view.state.selection.main;
var hadSelection = sel.to > sel.from;
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
var insert = before + selected + after;
var anchor, head;
if (hadSelection) {
anchor = head = sel.from + insert.length;
} else {
anchor = sel.from + before.length;
head = anchor + placeholder.length;
}
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: insert },
selection: { anchor: anchor, head: head },
scrollIntoView: true,
});
view.focus();
}
function linePrefix(prefix) {
var sel = view.state.selection.main;
var line = view.state.doc.lineAt(sel.from);
view.dispatch({
changes: { from: line.from, to: line.from, insert: prefix },
selection: { anchor: sel.from + prefix.length },
scrollIntoView: true,
});
view.focus();
}
function applyTableOp(fn, arg) {
var text = view.state.doc.toString();
var pos = view.state.selection.main.head;
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
if (result) dispatchFullReplace(result);
}
// Adapter passed to movie.js so it reads/writes the CM document instead of a
// textarea (replace() dispatches a transaction; cursor lands after the block).
var movieCtx = {
getValue: function () { return view.state.doc.toString(); },
replace: function (start, end, text) {
view.dispatch({
changes: { from: start, to: end, insert: text },
selection: { anchor: start + text.length },
scrollIntoView: true,
});
view.focus();
},
};
var editor = MarkdownEditor.create(mount, { doc: hidden.value });
// --- Content sync ---
@@ -163,7 +14,7 @@
// must not depend on CM focus or async state (decision 8). requestSubmit
// fires this listener even for the ALT+SHIFT+S path.
function syncContent() {
hidden.value = view.state.doc.toString();
hidden.value = editor.getValue();
}
// Save POSTs via fetch and then rewrites the *editor's* history entry with
@@ -208,246 +59,8 @@
// --- Actions ---
var actions = {
save: function () { form.requestSubmit(); },
undo: function () { CM.undo(view); view.focus(); },
redo: function () { CM.redo(view); view.focus(); },
deleteline: function () { CM.deleteLine(view); view.focus(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
embed: insertEmbed,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
fmttable: function () { applyTableOp(T.formatTableText); },
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
tblinsertcol: function () { applyTableOp(T.insertColumn); },
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
tblinsertrow: function () { applyTableOp(T.insertRow); },
tbldeleterow: function () { applyTableOp(T.deleteRow); },
dateiso: function () { insertAtCursor(D.isoDate()); },
datelong: function () { insertAtCursor(D.longDate()); },
movie: function () { M.run(movieCtx); },
};
editor.actions.save = function () { form.requestSubmit(); };
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
// plus an optional display-text field, inserting [[query]] or
// [[query::display]] at the cursor. Picking a suggestion only fills in that
// page's name — the link resolves through search at click time, so a path
// never enters the document. Inline `[[` typing uses the same suggestions
// via wikicomplete.js.
function insertWikilink() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var container = document.createElement('div');
var queryWrap = document.createElement('div');
var queryInput = document.createElement('input');
queryInput.type = 'text';
queryInput.className = 'input';
queryInput.placeholder = 'Search query';
// A selection is the query, not the label: [[selection]] renders with
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)';
container.appendChild(queryWrap);
container.appendChild(displayInput);
var handle = openModal({
title: 'Insert link',
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: !selectedText,
onConfirm: function () {
var query = queryInput.value.trim();
if (!query) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
}
}
});
function updateConfirm() {
handle.setConfirmDisabled(!queryInput.value.trim());
}
queryInput.addEventListener('input', updateConfirm);
window.attachSuggestions(queryInput, {
showFooter: false,
container: queryWrap,
onPick: function (r) {
queryInput.value = r.name;
updateConfirm();
displayInput.focus();
}
});
}
// 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
));
}
}
});
}
});
}
// --- Keyboard shortcut registration ---
var keyMap = {};
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = actions[btn.dataset.action];
if (action) action();
});
if (btn.dataset.key) {
keyMap[btn.dataset.key] = actions[btn.dataset.action];
}
});
// Keep the editor focused when a toolbar button is tapped. Without this the
// button steals focus on mousedown, which dismisses the mobile soft keyboard
// before the action runs (and view.focus() can't reopen it without a direct
// gesture). preventDefault on mousedown blocks the focus shift; click still
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
// dropdown toggles, which also must not pull focus off the editor.
var toolbar = document.querySelector('.editor-toolbar');
if (toolbar) {
toolbar.addEventListener('mousedown', function (e) {
if (e.target.closest('.btn')) e.preventDefault();
});
}
document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey) return;
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
// on US, "!" on DE), so fall back to e.code for digit rows.
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
var action = keyMap[key];
if (action) {
e.preventDefault();
action();
}
});
// --- Dropdowns ---
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
// menu to the viewport so it escapes the clip, opening upward when there
// isn't room below it (the bottom-toolbar case).
function pinMenu(toggle, menu) {
if (!menu.classList.contains('is-open')) return;
var r = toggle.getBoundingClientRect();
var vh = window.innerHeight;
menu.style.position = 'fixed';
menu.style.overflowY = 'auto';
var spaceBelow = vh - r.bottom;
var spaceAbove = r.top;
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
menu.style.top = 'auto';
menu.style.bottom = (vh - r.top) + 'px';
menu.style.maxHeight = (spaceAbove - 8) + 'px';
} else {
menu.style.bottom = 'auto';
menu.style.top = r.bottom + 'px';
menu.style.maxHeight = (spaceBelow - 8) + 'px';
}
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
menu.style.left = Math.max(4, left) + 'px';
}
document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
wireDropdown(toggle);
var menu = toggle.parentElement.querySelector('.dropdown-menu');
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
// Runs after wireDropdown's own click handler has toggled is-open.
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
});
// The toolbar and the header SAVE button both name actions.
MarkdownEditor.bind(document.body, function () { return editor.actions; });
})();
+67
View File
@@ -0,0 +1,67 @@
{{/* The markdown editor's toolbar, shared by the page editor and the canvas
node editor. Buttons name an action of editor/core.js (data-action) and its
Alt+Shift shortcut (data-key). */}}
{{define "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="redo" title="Redo">↷</button>
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
<button type="button" class="btn btn-tool btn-block" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
<button type="button" class="btn btn-tool btn-block" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
</div>
</span>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="task" data-key="X" title="Task list (X)">Task list</button>
</div>
</span>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignleft" title="Align left">Align left</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblaligncenter" title="Align center">Align center</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignright" title="Align right">Align right</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertcol" title="Insert column">Insert column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeletecol" title="Delete column">Delete column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertrow" title="Insert row">Insert row</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeleterow" title="Delete row">Delete row</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
<button type="button" class="btn btn-tool btn-block" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
</div>
</span>
</div>{{end}}
+3 -19
View File
@@ -1,27 +1,11 @@
// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one
// reloads the page with that chart's query parameter updated. Plain GET
// navigation — each view is distinct and bookmarkable, so no
// postReplace/history handling is needed.
// Fitness dashboard chart hover. The range dropdowns and "absolute"
// checkboxes are htmx (assets/fitness/main.html): they swap in only their
// chart.
// Boosted navigation re-runs this script on every visit; the listeners
// are delegated to document, so register them once.
if (!window.fitnessWired) {
window.fitnessWired = true;
document.addEventListener('change', function (e) {
var url = new URL(window.location.href);
var sel = e.target.closest('[data-fitness-range]');
var abs = e.target.closest('[data-fitness-absolute]');
if (sel) {
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
} else if (abs) {
if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1');
else url.searchParams.delete(abs.dataset.fitnessAbsolute);
} else {
return;
}
window.location.href = url.toString();
});
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots
// are sorted by x, so a binary search finds it; the tooltip sits above the
// point and is clamped to the chart panel.
+5 -3
View File
@@ -1,10 +1,12 @@
{{/* A chart's controls re-fetch only that chart (fitnessHandler.serveFragment),
sending every chart's controls so the server can update the page URL. */}}
{{define "fitnessChart"}}
<section class="fitness-chart panel">
<section class="fitness-chart panel" hx-get="./" hx-vals='{"chart": "{{.Param}}"}' hx-trigger="change" hx-include="closest .fitness-dash" hx-target="this" hx-swap="outerHTML" hx-sync="this:replace">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<div class="row fitness-controls">
<label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
<label class="fitness-absolute"><input type="checkbox" name="{{.Param}}-absolute" value="1"{{if .Absolute}} checked{{end}}> Absolute</label>
<select class="input fitness-range" name="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
+15 -14
View File
@@ -10,14 +10,26 @@
return document.body.getAttribute('hx-boost') === 'true';
}
// Only wiki pages are boosted: same origin, a slash-terminated path, no
// query except a search (?q=). Files, the editor (?edit), internal
// endpoints (/_…) and POST forms navigate normally.
function isPageURL(url) {
if (url.origin !== location.origin) return false;
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
var ok = true;
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
return ok;
}
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
// target into the current history entry rather than pushing a new one, so
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
// navigation; elsewhere replaceState + reload (not location.replace: a
// Back skips the stale pre-mutation page. Boosted pages swap a page target
// in like a navigation; anything else (or a target that is not a page,
// like a new canvas) gets replaceState + reload (not location.replace: a
// target differing only by #fragment would otherwise not re-fetch).
htmx.location = {
set href(target) {
if (boosting()) {
if (boosting() && isPageURL(new URL(target, location.href))) {
if (window.closeModal) closeModal();
htmx.ajax('GET', target, {
target: '.shell',
@@ -60,17 +72,6 @@
// snapshots that could show a page from before a move or delete).
htmx.config.scrollIntoViewOnBoost = false;
// Only wiki pages are boosted: same origin, a slash-terminated path, no
// query except a search (?q=). Files, the editor (?edit), internal
// endpoints (/_…) and POST forms navigate normally.
function isPageURL(url) {
if (url.origin !== location.origin) return false;
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
var ok = true;
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
return ok;
}
// htmx decides per element whether it is boosted, right after this event.
document.addEventListener('htmx:beforeProcessNode', function (e) {
var elt = e.target;
+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 2h6v4H1zM9 10h6v4H9zM4 6v6h5"/>
</svg>

After

Width:  |  Height:  |  Size: 250 B

+3 -2
View File
@@ -23,8 +23,9 @@
</head>
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell
(plus title and page actions, see "partial") instead of loading a new
document. hx.js decides which links count as pages. */}}
<body{{if not .EditMode}} hx-boost="true"{{end}}>
document. hx.js decides which links count as pages. A view can opt out by
overriding "boost" (the canvas does: it saves on pagehide). */}}
<body{{if not .EditMode}}{{block "boost" .}} hx-boost="true"{{end}}{{end}}>
<header>
<nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
+13
View File
@@ -16,6 +16,19 @@ confirmation), or fail with a plain-text error. */}}
</form>
</div>{{end}}
{{define "canvas"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?canvas" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">New canvas — name?</span></div>
<div class="panel-body">
<input class="input" type="text" name="name" placeholder="Canvas name" required autofocus />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit">CREATE</button>
</div>
</form>
</div>{{end}}
{{define "move"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Move — new name?</span></div>
+1
View File
@@ -1,5 +1,6 @@
{{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
<button class="btn btn-block" hx-get="?dialog=canvas" hx-target="#modal-host" title="New canvas on this page"><span class="btn-icon">{{icon "canvas"}}</span><span class="btn-label">CANVAS</span></button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
+305 -1
View File
@@ -46,6 +46,15 @@
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;
/* JSON Canvas preset colors "1"–"6": red, orange, yellow, green, cyan,
purple. */
--canvas-1: #e0525b;
--canvas-2: #e08a3a;
--canvas-3: #d6c94d;
--canvas-4: #5fb35b;
--canvas-5: #01b6c4;
--canvas-6: #a46ad6;
}
/* Cross-document view transitions: page navigations stay full reloads, but the
@@ -461,7 +470,7 @@ main > h2 {
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
than breaking into stacked rows. A horizontal-scroll container also clips
overflow-y, so open dropdown menus are pinned to the viewport via JS
(editor/main.js pinMenu) to escape the clip. */
(editor/core.js pinMenu) to escape the clip. */
.editor-toolbar {
display: flex;
flex-wrap: nowrap;
@@ -929,6 +938,301 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; }
}
.fitness-tip[hidden] { display: none; }
/* === Canvas ===
The canvas view (canvas/main.html) fills the shell. The world is one
transformed layer holding nodes in canvas units; canvas.js sets its
transform and --z (the zoom), which keeps handles and strokes a constant
size on screen. Groups sit below the edges, everything else above. */
.canvas-app {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.canvas-error { padding: var(--space-2) var(--space-4); color: var(--danger); }
/* The stage holds the viewport and what floats over it: the status line,
the context menu and the edit window. */
.canvas-stage { position: relative; flex: 1 1 auto; min-height: 0; }
.canvas-statusbar {
position: absolute;
left: var(--space-2);
bottom: var(--space-2);
z-index: 10;
display: flex;
gap: var(--space-3);
padding: 0 var(--space-2);
background: var(--bg);
pointer-events: none;
}
.canvas-status.is-error { color: var(--danger); pointer-events: auto; }
.canvas-editor-toolbar[hidden] { display: none; }
.canvas-viewport {
position: absolute;
inset: 0;
overflow: hidden;
touch-action: none;
outline: none;
-webkit-touch-callout: none;
background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1.5px);
}
.canvas-viewport.is-far { background-image: none; }
.canvas-viewport.is-panning { cursor: grabbing; }
.canvas-world, .canvas-nodes, .canvas-edge-labels { position: absolute; left: 0; top: 0; }
.canvas-world { transform-origin: 0 0; }
.canvas-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
pointer-events: none;
}
.canvas-hint[hidden] { display: none; }
.canvas-marquee {
position: absolute;
border: 1px dashed var(--primary-hover);
background: color-mix(in srgb, var(--primary-hover) 10%, transparent);
pointer-events: none;
}
/* Preset colors, for nodes, edges and the color menu's swatches. A hex color
arrives as an inline --node-color / --edge-color. */
.canvas-app [data-color="1"] { --node-color: var(--canvas-1); --edge-color: var(--canvas-1); }
.canvas-app [data-color="2"] { --node-color: var(--canvas-2); --edge-color: var(--canvas-2); }
.canvas-app [data-color="3"] { --node-color: var(--canvas-3); --edge-color: var(--canvas-3); }
.canvas-app [data-color="4"] { --node-color: var(--canvas-4); --edge-color: var(--canvas-4); }
.canvas-app [data-color="5"] { --node-color: var(--canvas-5); --edge-color: var(--canvas-5); }
.canvas-app [data-color="6"] { --node-color: var(--canvas-6); --edge-color: var(--canvas-6); }
.canvas-node {
--node-color: var(--secondary);
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
border: 1px solid var(--node-color);
background: color-mix(in srgb, var(--node-color) var(--node-tint, 0%), var(--bg-panel));
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.canvas-node[data-color],
.canvas-node[style*="--node-color"] { --node-tint: 14%; }
.canvas-node.is-selected { outline: calc(2px / var(--z, 1)) solid var(--primary-hover); outline-offset: calc(3px / var(--z, 1)); }
.canvas-node.is-pending { opacity: 0.6; }
.canvas-node-group {
z-index: 0;
border-style: dashed;
background: color-mix(in srgb, var(--node-color) var(--node-tint, 0%), transparent);
isolation: isolate;
}
.canvas-node-group[data-color],
.canvas-node-group[style*="--node-color"] { --node-tint: 8%; }
.canvas-node-label {
position: absolute;
bottom: 100%;
left: 0;
max-width: 100%;
padding-bottom: 0.15em;
font-size: var(--font-sm);
color: var(--node-color);
}
.canvas-node-group > .canvas-node-label { font-size: 1.1rem; }
.canvas-node-label a { color: inherit; }
.canvas-node-label a:hover { color: var(--link-hover); }
.canvas-node-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: var(--space-2) var(--space-3);
word-break: break-word;
}
.canvas-node-body.content { margin: 0; }
.canvas-node-body > :first-child { margin-top: 0; }
.canvas-node-body > :last-child { margin-bottom: 0; }
.canvas-node-body[hidden] { display: none; }
.canvas-node-group > .canvas-node-body { padding: 0; overflow: hidden; }
.canvas-node-file > .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; }
video.canvas-media { object-fit: contain; background: #000; }
audio.canvas-media { height: auto; }
.canvas-card {
display: flex;
align-items: center;
gap: var(--space-2);
height: 100%;
min-width: 0;
color: var(--link);
}
.canvas-card:hover { color: var(--link-hover); }
.canvas-card-icon { flex: none; }
.canvas-card-text { display: flex; flex-direction: column; min-width: 0; }
.canvas-missing { color: var(--danger); font-size: var(--font-sm); }
.canvas-group-bg { position: absolute; inset: 0; z-index: -1; background-position: center; opacity: 0.5; }
.canvas-group-bg-cover { background-size: cover; background-repeat: no-repeat; }
.canvas-group-bg-ratio { background-size: contain; background-repeat: no-repeat; }
.canvas-group-bg-repeat { background-repeat: repeat; }
/* Handles: connect from a side, resize from the corner. */
.canvas-handle, .canvas-resize { position: absolute; display: none; z-index: 1; }
.canvas-handle {
width: calc(14px / var(--z, 1));
height: calc(14px / var(--z, 1));
border-radius: 50%;
border: calc(2px / var(--z, 1)) solid var(--primary-hover);
background: var(--bg);
transform: translate(-50%, -50%);
cursor: crosshair;
}
.canvas-handle[data-side="top"] { left: 50%; top: 0; }
.canvas-handle[data-side="right"] { left: 100%; top: 50%; }
.canvas-handle[data-side="bottom"] { left: 50%; top: 100%; }
.canvas-handle[data-side="left"] { left: 0; top: 50%; }
.canvas-resize {
right: 0;
bottom: 0;
width: calc(16px / var(--z, 1));
height: calc(16px / var(--z, 1));
background: linear-gradient(135deg, transparent 55%, var(--primary-hover) 55%);
cursor: nwse-resize;
}
.canvas-node.is-selected > .canvas-handle,
.canvas-node.is-selected > .canvas-resize { display: block; }
@media (hover: hover) {
.canvas-node:hover > .canvas-handle { display: block; }
}
/* Edges: an SVG sized to their bounding box (canvas.js). The wide invisible
hit path makes thin lines easy to click. */
.canvas-edges { position: absolute; z-index: 1; overflow: visible; pointer-events: none; }
.canvas-edge { --edge-color: var(--text-muted); }
.canvas-edge-line {
fill: none;
stroke: var(--edge-color);
stroke-width: max(2px, calc(1.5px / var(--z, 1)));
}
.canvas-edge-hit {
fill: none;
stroke: transparent;
stroke-width: calc(16px / var(--z, 1));
pointer-events: stroke;
cursor: pointer;
}
.canvas-edge-arrow { fill: var(--edge-color); }
.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); }
.canvas-edge.is-selected .canvas-edge-arrow { fill: var(--primary-hover); }
.canvas-edge.is-draft .canvas-edge-line { stroke: var(--primary-hover); stroke-dasharray: 6 4; }
.canvas-edge-label {
--edge-color: var(--text-muted);
position: absolute;
z-index: 3;
transform: translate(-50%, -50%);
padding: 0 var(--space-1);
background: var(--bg);
color: var(--edge-color);
font-size: var(--font-sm);
white-space: pre;
cursor: pointer;
}
.canvas-edge-label.is-selected { color: var(--primary-hover); }
/* Context menu (canvas.js openMenu): fixed at the pointer. */
.canvas-menu {
position: fixed;
z-index: 100;
min-width: 12rem;
padding: var(--space-1) 0;
font-size: var(--font-sm);
}
.canvas-menu .btn-block { padding: 0.3rem var(--space-3); }
.canvas-menu .btn:disabled { color: var(--text-muted); cursor: default; }
.canvas-menu-sep { border-top: var(--border-dashed); margin: var(--space-1) 0; }
.canvas-menu-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.3rem var(--space-3);
}
.canvas-menu-row > .muted { min-width: 3rem; }
.canvas-swatch {
width: 1.1rem;
height: 1.1rem;
padding: 0;
border: var(--border);
background: var(--node-color, transparent);
cursor: pointer;
}
.canvas-swatch:not([data-color]):not(input) {
background: linear-gradient(to top right, transparent 45%, var(--text-muted) 45%, var(--text-muted) 55%, transparent 55%);
}
.canvas-swatch.is-current { outline: 2px solid var(--primary-hover); outline-offset: 1px; }
input.canvas-swatch { background: none; }
/* Edit window (canvas.js openWindow): not modal, placed beside its card;
small screens dock it to the bottom. */
.canvas-window {
position: absolute;
z-index: 20;
width: 24rem;
max-width: calc(100% - 2rem);
max-height: calc(100% - 2rem);
display: flex;
flex-direction: column;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.4);
}
.canvas-window.is-wide { width: 34rem; height: 24rem; resize: both; overflow: hidden; min-width: 18rem; min-height: 12rem; }
.canvas-window .panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
font-size: var(--font-sm);
color: var(--text);
padding: 0.4rem var(--space-4);
margin-bottom: 0;
cursor: move;
user-select: none;
}
.canvas-window .panel-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
.canvas-window .panel-footer { justify-content: flex-end; }
.canvas-window .input { background: var(--bg); }
.canvas-field { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.canvas-field-label { font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.canvas-field-row { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
.canvas-field-row > :first-child { flex: 1 1 auto; min-width: 0; }
.canvas-field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.canvas-fields { display: flex; flex-direction: column; gap: var(--space-3); }
.canvas-window.is-wide .panel-body { padding: 0; }
.canvas-window-editor { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.canvas-window-editor .editor-toolbar { position: static; border-left: none; border-right: none; border-top: none; }
.canvas-window-cm { flex: 1 1 auto; min-height: 0; display: flex; }
.canvas-window-cm .cm-editor { flex: 1 1 auto; min-width: 0; border: none; }
.canvas-window-cm .cm-editor .cm-scroller { min-height: 0; }
.canvas-window-cm .cm-editor .cm-content { padding: var(--space-2) var(--space-3); }
@media (max-width: 600px) {
.canvas-window, .canvas-window.is-wide {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: auto;
width: auto;
max-width: none;
height: auto;
max-height: 60vh;
resize: none;
padding-bottom: env(safe-area-inset-bottom);
}
.canvas-window.is-wide { height: 50vh; }
.canvas-window .panel-header { cursor: default; }
.canvas-menu .btn-block { min-height: 2.5rem; align-items: center; }
.canvas-swatch { width: 1.6rem; height: 1.6rem; }
}
/* === Responsive === */
@media (max-width: 1100px) {
.tree-sidebar, .sidebar { display: none; }
+586
View File
@@ -0,0 +1,586 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
)
// Canvases are JSON Canvas 1.0 files (https://jsoncanvas.org) stored as
// <name>.canvas inside a page's folder, so a page can hold any number of them.
//
// GET /Page/Board.canvas the canvas view (canvas/main.html)
// GET /Page/Board.canvas?raw the file itself
// POST /Page/Board.canvas?save the whole document (autosave, canvas.js)
// POST /Page/Board.canvas?render one node → its rendered element
// POST /Page/?canvas create a canvas (dialog "canvas")
//
// The canvas URL sits next to the page, so relative links and embeds in text
// nodes resolve against the page folder exactly like they do in index.md.
//
// The server only reads the fields it renders. Saves write the client's JSON
// back as sent (reindented), so fields this code does not know survive.
const canvasExt = ".canvas"
var canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets,
"assets/layout.html", "assets/editor/toolbar.html", "assets/canvas/main.html", "assets/canvas/node.html"))
func isCanvasFile(name string) bool {
return strings.EqualFold(path.Ext(name), canvasExt)
}
type canvasNode struct {
ID string `json:"id"`
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Color string `json:"color,omitempty"`
Text string `json:"text,omitempty"`
File string `json:"file,omitempty"`
Subpath string `json:"subpath,omitempty"`
URL string `json:"url,omitempty"`
Label string `json:"label,omitempty"`
Background string `json:"background,omitempty"`
BackgroundStyle string `json:"backgroundStyle,omitempty"`
}
type canvasEdge struct {
ID string `json:"id"`
FromNode string `json:"fromNode"`
FromSide string `json:"fromSide,omitempty"`
FromEnd string `json:"fromEnd,omitempty"`
ToNode string `json:"toNode"`
ToSide string `json:"toSide,omitempty"`
ToEnd string `json:"toEnd,omitempty"`
Color string `json:"color,omitempty"`
Label string `json:"label,omitempty"`
}
type canvasDoc struct {
Nodes []canvasNode `json:"nodes"`
Edges []canvasEdge `json:"edges"`
}
// emptyCanvas is what a new canvas file holds, and what an empty file reads as.
var emptyCanvas = []byte("{\n\t\"nodes\": [],\n\t\"edges\": []\n}\n")
// parseCanvas decodes and validates a canvas document. An empty file is an
// empty canvas.
func parseCanvas(raw []byte) (canvasDoc, error) {
var doc canvasDoc
if len(bytes.TrimSpace(raw)) == 0 {
return doc, nil
}
// The top level must be an object; Unmarshal into a struct would accept
// `null` silently.
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return doc, fmt.Errorf("not a JSON Canvas object: %v", err)
}
if err := json.Unmarshal(raw, &doc); err != nil {
return doc, fmt.Errorf("invalid canvas: %v", err)
}
return doc, validateCanvas(doc)
}
var canvasHexColorRe = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// canvasColor splits a canvas color into a preset ("1"–"6") or a hex value.
// Anything else is no color.
func canvasColor(c string) (preset string, hex template.CSS) {
if len(c) == 1 && c[0] >= '1' && c[0] <= '6' {
return c, ""
}
if canvasHexColorRe.MatchString(c) {
return "", template.CSS(c)
}
return "", ""
}
func validCanvasColor(c string) bool {
p, h := canvasColor(c)
return c == "" || p != "" || h != ""
}
func validCanvasSide(s string) bool {
switch s {
case "", "top", "right", "bottom", "left":
return true
}
return false
}
func validCanvasEnd(s string) bool {
return s == "" || s == "none" || s == "arrow"
}
// validateCanvas checks what the renderer and editor rely on: unique ids,
// known node types with their required field, finite geometry, and edges
// between existing nodes.
func validateCanvas(doc canvasDoc) error {
ids := map[string]bool{}
for i, n := range doc.Nodes {
if n.ID == "" {
return fmt.Errorf("node %d has no id", i)
}
if ids[n.ID] {
return fmt.Errorf("duplicate id %q", n.ID)
}
ids[n.ID] = true
for _, v := range []float64{n.X, n.Y, n.Width, n.Height} {
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Errorf("node %q has invalid geometry", n.ID)
}
}
switch n.Type {
case "text", "group":
case "file":
if n.File == "" {
return fmt.Errorf("file node %q has no file", n.ID)
}
case "link":
if n.URL == "" {
return fmt.Errorf("link node %q has no url", n.ID)
}
default:
return fmt.Errorf("node %q has unknown type %q", n.ID, n.Type)
}
if !validCanvasColor(n.Color) {
return fmt.Errorf("node %q has invalid color %q", n.ID, n.Color)
}
}
edgeIDs := map[string]bool{}
for i, e := range doc.Edges {
if e.ID == "" {
return fmt.Errorf("edge %d has no id", i)
}
if edgeIDs[e.ID] {
return fmt.Errorf("duplicate edge id %q", e.ID)
}
edgeIDs[e.ID] = true
if !ids[e.FromNode] || !ids[e.ToNode] {
return fmt.Errorf("edge %q connects a missing node", e.ID)
}
if !validCanvasSide(e.FromSide) || !validCanvasSide(e.ToSide) {
return fmt.Errorf("edge %q has an invalid side", e.ID)
}
if !validCanvasEnd(e.FromEnd) || !validCanvasEnd(e.ToEnd) {
return fmt.Errorf("edge %q has an invalid end", e.ID)
}
if !validCanvasColor(e.Color) {
return fmt.Errorf("edge %q has invalid color %q", e.ID, e.Color)
}
}
return nil
}
// canvasVersion identifies one exact file content. Saves carry the version
// they were based on; a mismatch means someone else changed the file.
func canvasVersion(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:8])
}
// canvasMu serializes the version check and write of canvas saves.
var canvasMu sync.Mutex
// === Rendering ===
// canvasNodeView is one node element (canvas/node.html). Geometry is in
// canvas units; the client positions the world, not the nodes.
type canvasNodeView struct {
ID, Type string
X, Y, W, H int
Preset string
Hex template.CSS
Label string
LabelURL string
Markdown bool // body is rendered markdown (.content styling)
Body template.HTML
}
// canvasBody* feed the node body templates in canvas/node.html.
type canvasMediaBody struct {
URL, Thumb, Kind string
}
type canvasCardBody struct {
Icon template.HTML
Name, URL string
Host string
Label string // a link's display text, shown instead of its URL
}
type canvasGroupBody struct {
URL string
Style string
}
func (h *handler) renderCanvasNode(n canvasNode) canvasNodeView {
v := canvasNodeView{
ID: n.ID,
Type: n.Type,
X: int(math.Round(n.X)),
Y: int(math.Round(n.Y)),
W: int(math.Round(n.Width)),
H: int(math.Round(n.Height)),
}
v.Preset, v.Hex = canvasColor(n.Color)
switch n.Type {
case "text":
v.Markdown = true
v.Body = template.HTML(canvasTaskCheckboxes(convertMarkdown([]byte(n.Text), "")))
case "file":
h.renderCanvasFile(&v, n)
case "link":
host := n.URL
if u, err := url.Parse(n.URL); err == nil && u.Host != "" {
host = u.Host
}
v.Body = execNodeTemplate("canvas-link", canvasCardBody{Name: n.URL, URL: n.URL, Host: host, Label: strings.TrimSpace(n.Label)})
case "group":
v.Label = n.Label
if n.Background != "" {
if rel, _, ok := h.canvasFilePath(n.Background); ok {
style := n.BackgroundStyle
if style != "ratio" && style != "repeat" {
style = "cover"
}
v.Body = execNodeTemplate("canvas-group-bg", canvasGroupBody{URL: fileURL(rel), Style: style})
}
}
}
return v
}
// canvasFilePath resolves a file node's path. Paths are relative to the wiki
// root (the spec's "vault"); a leading slash is accepted. Dot segments are
// refused like everywhere else.
func (h *handler) canvasFilePath(p string) (rel, fsPath string, ok bool) {
rel = strings.Trim(path.Clean("/"+strings.ReplaceAll(p, "\\", "/")), "/")
if hasDotSegment(rel) {
return "", "", false
}
return rel, filepath.Join(h.root, filepath.FromSlash(rel)), true
}
func (h *handler) renderCanvasFile(v *canvasNodeView, n canvasNode) {
rel, fsPath, ok := h.canvasFilePath(n.File)
v.Label = path.Base("/" + rel)
info, err := os.Stat(fsPath)
if !ok || err != nil {
v.Body = execNodeTemplate("canvas-missing", n.File)
return
}
if info.IsDir() {
// A folder is a page: show its index.md.
v.LabelURL = pageURL(rel)
raw, _ := os.ReadFile(filepath.Join(fsPath, "index.md"))
if heading := extractFirstHeading(raw); heading != "" {
v.Label = heading
}
canvasMarkdownBody(v, raw, n.Subpath, dirBase(rel))
return
}
u := fileURL(rel)
v.LabelURL = u
name := info.Name()
ext := strings.ToLower(path.Ext(name))
switch {
case ext == ".md":
raw, _ := os.ReadFile(fsPath)
canvasMarkdownBody(v, raw, n.Subpath, dirBase(path.Dir("/"+rel)))
case isImageFile(name) || ext == ".svg" || ext == ".webp":
thumb := u
if hasThumbnail(name) {
thumb = u + "?w=800"
}
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Thumb: thumb, Kind: "image"})
case isVideoFile(name):
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "video"})
case ext == ".mp3" || ext == ".flac" || ext == ".ogg" || ext == ".wav":
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "audio"})
default:
v.Body = execNodeTemplate("canvas-file", canvasCardBody{Icon: fileIcon(name), Name: name, URL: u})
}
}
// dirBase is the base URL path for markdown that lives in the folder rel
// (wiki-relative, "" or "/" for the root).
func dirBase(rel string) string {
rel = strings.Trim(rel, "/")
if rel == "" {
return "/"
}
return "/" + rel + "/"
}
// canvasMarkdownBody renders a markdown file node: the whole file, or the
// section its subpath names. A subpath that names no heading says so on the
// card rather than showing the whole file (as jsoncanvas-desktop does).
func canvasMarkdownBody(v *canvasNodeView, raw []byte, subpath, base string) {
part, ok := canvasSubpath(raw, subpath)
if !ok {
v.Body = execNodeTemplate("canvas-missing", subpath)
return
}
v.Markdown = true
v.Body = template.HTML(convertMarkdown(part, base))
}
// canvasSubpath narrows markdown to a file node's "#Heading" subpath: that
// heading's section including its subsections, matched case-insensitively
// on the heading text. Without a subpath it is the whole file; ok is false
// for a subpath that names no heading, which includes block references
// ("#^id").
func canvasSubpath(raw []byte, subpath string) (part []byte, ok bool) {
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
if want == "" {
return raw, true
}
sections := splitSections(raw)
for i, s := range sections {
if _, text := sectionHeading(s); i > 0 && strings.EqualFold(text, want) {
return joinSections(sections[i:secionSpanEnd(sections, i)]), true
}
}
return nil, false
}
// canvasTaskCheckboxes makes a text node's task checkboxes clickable. The
// canvas editor (canvas.js) flips the Nth task line of the node's text and
// saves the whole canvas, so they carry only their index.
func canvasTaskCheckboxes(in []byte) []byte {
idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
out := `<input type="checkbox" class="task-checkbox" data-task="` + strconv.Itoa(idx) + `"`
if bytes.Contains(match, []byte("checked")) {
out += ` checked=""`
}
idx++
return []byte(out + ">")
})
}
func execNodeTemplate(name string, data any) template.HTML {
var buf bytes.Buffer
if err := canvasTmpl.ExecuteTemplate(&buf, name, data); err != nil {
log.Printf("canvas template %s: %v", name, err)
}
return template.HTML(buf.String())
}
// === Handlers ===
type canvasPageData struct {
Title string
EditMode bool
CanEdit bool
IsRoot bool
PageURL string // the page the canvas belongs to
RawURL string
Name string
Nodes []canvasNodeView
Doc json.RawMessage
Version string
Error string
renderTimer
}
// serveCanvas handles every request to an existing .canvas file.
func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
q := r.URL.Query()
switch {
case r.Method == http.MethodGet && q.Has("raw"):
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.ServeFile(w, r, fsPath)
case r.Method == http.MethodGet:
h.serveCanvasPage(w, r, urlPath, fsPath)
case r.Method == http.MethodPost && q.Has("save"):
h.handleCanvasSave(w, r, fsPath)
case r.Method == http.MethodPost && q.Has("render"):
h.handleCanvasRender(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *handler) serveCanvasPage(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
raw, err := os.ReadFile(fsPath)
if err != nil {
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
name := path.Base(urlPath)
data := canvasPageData{
Title: strings.TrimSuffix(name, path.Ext(name)),
CanEdit: true,
PageURL: pageURL(path.Dir(urlPath)),
RawURL: fileURL(strings.TrimPrefix(urlPath, "/")) + "?raw",
Name: name,
Version: canvasVersion(raw),
Doc: json.RawMessage(emptyCanvas),
}
doc, err := parseCanvas(raw)
if err != nil {
data.Error = err.Error()
} else {
if len(bytes.TrimSpace(raw)) > 0 {
data.Doc = json.RawMessage(raw)
}
for _, n := range doc.Nodes {
data.Nodes = append(data.Nodes, h.renderCanvasNode(n))
}
}
data.renderTimer = renderTimer{requestStart(r)}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("canvas template error: %v", err)
}
}
// maxCanvasBytes caps a saved canvas; text nodes hold markdown, not files.
const maxCanvasBytes = 10 << 20
// handleCanvasSave replaces the canvas with the posted document. The client
// sends the version it last loaded or saved (X-Canvas-Version); when the file
// has changed since, the save is refused with 409 so nothing is overwritten.
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, fsPath string) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCanvasBytes))
if err != nil {
http.Error(w, "canvas too large", http.StatusRequestEntityTooLarge)
return
}
if len(bytes.TrimSpace(body)) == 0 {
http.Error(w, "refusing to save an empty canvas body", http.StatusBadRequest)
return
}
if _, err := parseCanvas(body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var out bytes.Buffer
if err := json.Indent(&out, body, "", "\t"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
out.WriteByte('\n')
canvasMu.Lock()
defer canvasMu.Unlock()
current, err := os.ReadFile(fsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "this canvas no longer exists", http.StatusNotFound)
return
}
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("X-Canvas-Version") != canvasVersion(current) {
http.Error(w, "the canvas changed since it was opened — reload to see the current version", http.StatusConflict)
return
}
if err := writeFileAtomic(fsPath, out.Bytes(), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("X-Canvas-Version", canvasVersion(out.Bytes()))
w.WriteHeader(http.StatusNoContent)
}
// handleCanvasRender renders one posted node as its element, so the editor
// shows nodes exactly as the server renders them on load.
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
var n canvasNode
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCanvasBytes)).Decode(&n); err != nil {
http.Error(w, "bad node: "+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := canvasTmpl.ExecuteTemplate(w, "canvas-node", h.renderCanvasNode(n)); err != nil {
log.Printf("canvas template error: %v", err)
}
}
// canvasFileName validates a new canvas's name from the create dialog and
// returns the file name, with the extension added when missing.
func canvasFileName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if isCanvasFile(name) {
name = strings.TrimSpace(name[:len(name)-len(canvasExt)])
}
if name == "" {
return "", fmt.Errorf("name is empty")
}
if strings.HasPrefix(name, ".") || strings.ContainsAny(name, "/\\\x00") {
return "", fmt.Errorf("name must not start with a dot or contain slashes")
}
return name + canvasExt, nil
}
// handleCanvasCreate creates an empty canvas in the page's folder (creating
// the folder for a page that does not exist yet) and opens it.
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name, err := canvasFileName(r.FormValue("name"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, statErr := os.Stat(fsPath)
newlyCreated := os.IsNotExist(statErr)
if err := os.MkdirAll(fsPath, 0755); err != nil {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
f, err := os.OpenFile(filepath.Join(fsPath, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
if errors.Is(err, os.ErrExist) {
http.Error(w, name+" already exists", http.StatusConflict)
return
}
http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError)
return
}
_, werr := f.Write(emptyCanvas)
if cerr := f.Close(); werr == nil {
werr = cerr
}
if werr != nil {
http.Error(w, "write failed: "+werr.Error(), http.StatusInternalServerError)
return
}
if newlyCreated {
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
folderIndexAdd(filepath.ToSlash(rel))
}
}
rel := strings.Trim(urlPath, "/")
if rel != "" {
rel += "/"
}
redirectAfter(w, r, fileURL(rel+name))
}
+286
View File
@@ -0,0 +1,286 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func canvasRequest(h *handler, method, target, body string, header map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, target, strings.NewReader(body))
for k, v := range header {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestParseCanvas(t *testing.T) {
ok := []string{
``,
`{}`,
`{"nodes":[{"id":"a","type":"text","x":0,"y":0,"width":10,"height":10,"text":"hi","color":"3"},
{"id":"b","type":"group","x":0,"y":0,"width":10,"height":10,"color":"#ff8800"}],
"edges":[{"id":"e","fromNode":"a","toNode":"b","fromSide":"left","toEnd":"none"}]}`,
}
for _, src := range ok {
if _, err := parseCanvas([]byte(src)); err != nil {
t.Errorf("parseCanvas(%s): %v", src, err)
}
}
bad := map[string]string{
"not an object": `[]`,
"syntax": `{"nodes":[`,
"no id": `{"nodes":[{"type":"text","x":0,"y":0,"width":1,"height":1}]}`,
"duplicate id": `{"nodes":[{"id":"a","type":"text"},{"id":"a","type":"text"}]}`,
"unknown type": `{"nodes":[{"id":"a","type":"blob"}]}`,
"file w/o path": `{"nodes":[{"id":"a","type":"file"}]}`,
"link w/o url": `{"nodes":[{"id":"a","type":"link"}]}`,
"bad color": `{"nodes":[{"id":"a","type":"text","color":"red;x"}]}`,
"string x": `{"nodes":[{"id":"a","type":"text","x":"1"}]}`,
"dangling edge": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"zz"}]}`,
"bad side": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toSide":"up"}]}`,
"bad end": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toEnd":"dot"}]}`,
}
for name, src := range bad {
if _, err := parseCanvas([]byte(src)); err == nil {
t.Errorf("%s: parseCanvas accepted %s", name, src)
}
}
}
func TestCanvasSave(t *testing.T) {
initMarkdown()
root := t.TempDir()
file := filepath.Join(root, "Page", "Board.canvas")
writeTestFile(t, file, string(emptyCanvas))
h := &handler{root: root}
save := func(body, version string) *httptest.ResponseRecorder {
return canvasRequest(h, "POST", "/Page/Board.canvas?save", body, map[string]string{"X-Canvas-Version": version})
}
// Fields the server does not know survive the round trip.
doc := `{"nodes":[{"id":"a","type":"text","x":1,"y":2,"width":3,"height":4,"text":"hi","custom":{"k":1}}],"edges":[],"extra":true}`
w := save(doc, canvasVersion(emptyCanvas))
if w.Code != http.StatusNoContent {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
saved, _ := os.ReadFile(file)
var got map[string]any
if err := json.Unmarshal(saved, &got); err != nil {
t.Fatal(err)
}
if got["extra"] != true || !strings.Contains(string(saved), `"custom"`) {
t.Errorf("unknown fields lost:\n%s", saved)
}
if v := w.Header().Get("X-Canvas-Version"); v != canvasVersion(saved) {
t.Errorf("returned version %q does not match the file", v)
}
// A save based on an older version is refused and changes nothing.
if w := save(`{"nodes":[],"edges":[]}`, canvasVersion(emptyCanvas)); w.Code != http.StatusConflict {
t.Errorf("stale save: got %d, want 409", w.Code)
}
if now, _ := os.ReadFile(file); string(now) != string(saved) {
t.Error("stale save changed the file")
}
// Invalid documents are refused.
if w := save(`{"nodes":[{"id":"a","type":"nope"}]}`, canvasVersion(saved)); w.Code != http.StatusBadRequest {
t.Errorf("invalid save: got %d, want 400", w.Code)
}
if w := save(``, canvasVersion(saved)); w.Code != http.StatusBadRequest {
t.Errorf("empty save: got %d, want 400", w.Code)
}
// A canvas deleted while open is not recreated as a page.
os.Remove(file)
if w := save(doc, canvasVersion(saved)); w.Code != http.StatusNotFound {
t.Errorf("save to deleted canvas: got %d, want 404", w.Code)
}
if _, err := os.Stat(filepath.Join(root, "Page", "Board.canvas")); !os.IsNotExist(err) {
t.Error("save to deleted canvas created something")
}
}
func TestCanvasPage(t *testing.T) {
initMarkdown()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "Page", "Board.canvas"),
`{"nodes":[{"id":"n1","type":"text","x":10,"y":20,"width":200,"height":100,"text":"# Hello\n\n- [ ] task","color":"2"}],"edges":[]}`)
writeTestFile(t, filepath.Join(root, "Page", "Broken.canvas"), `{"nodes":[`)
h := &handler{root: root}
w := canvasRequest(h, "GET", "/Page/Board.canvas", "", nil)
body := w.Body.String()
for _, want := range []string{
`class="canvas-app"`,
`data-id="n1"`,
`data-color="2"`,
`left: 10px; top: 20px; width: 200px; height: 100px;`,
`<h1 id="hello">Hello</h1>`,
`class="task-checkbox" data-task="0"`,
`id="canvas-data"`,
} {
if !strings.Contains(body, want) {
t.Errorf("canvas page lacks %q", want)
}
}
for _, unwanted := range []string{`tree-sidebar`, `hx-boost="true"`, `<footer`, `hx-post="?toggle`} {
if strings.Contains(body, unwanted) {
t.Errorf("canvas page contains %q", unwanted)
}
}
if w := canvasRequest(h, "GET", "/Page/Board.canvas?raw", "", nil); !strings.Contains(w.Body.String(), `"n1"`) ||
!strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
t.Errorf("?raw: %s %s", w.Header().Get("Content-Type"), w.Body)
}
w = canvasRequest(h, "GET", "/Page/Broken.canvas", "", nil)
if body := w.Body.String(); !strings.Contains(body, "data-readonly") || !strings.Contains(body, "cannot be edited") {
t.Errorf("broken canvas is not read-only:\n%s", body)
}
}
func TestCanvasRenderNodes(t *testing.T) {
initMarkdown()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "Other Page", "index.md"), "# Other\n\n![shot](<pic one.jpg>)\n\n## Part\n\nPart text\n\n## Rest\n\nRest text\n")
writeTestFile(t, filepath.Join(root, "Other Page", "notes.md"), "[rel](sub/x.md) [abs](/y/) [web](https://e.x)\n")
writeTestFile(t, filepath.Join(root, "Other Page", "pic one.jpg"), "not really")
writeTestFile(t, filepath.Join(root, "Other Page", "data.zip"), "zip")
writeTestFile(t, filepath.Join(root, ".secret", "x.md"), "secret")
writeTestFile(t, filepath.Join(root, "Board.canvas"), "")
h := &handler{root: root}
render := func(node string) string {
w := canvasRequest(h, "POST", "/Board.canvas?render", node, nil)
if w.Code != 200 {
t.Fatalf("render %s: %d %s", node, w.Code, w.Body)
}
return w.Body.String()
}
page := render(`{"id":"a","type":"file","file":"Other Page","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(page, `href="/Other%20Page/"`) || !strings.Contains(page, ">Other<") ||
!strings.Contains(page, `src="/Other%20Page/pic%20one.jpg?w=300"`) {
t.Errorf("page node:\n%s", page)
}
part := render(`{"id":"a","type":"file","file":"/Other Page/index.md","subpath":"#Part","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(part, "Part text") || strings.Contains(part, "Rest text") || strings.Contains(part, "shot") {
t.Errorf("subpath node:\n%s", part)
}
notes := render(`{"id":"a","type":"file","file":"Other Page/notes.md","x":0,"y":0,"width":1,"height":1}`)
for _, want := range []string{`href="/Other%20Page/sub/x.md"`, `href="/y/"`, `href="https://e.x"`} {
if !strings.Contains(notes, want) {
t.Errorf("markdown file node lacks %s:\n%s", want, notes)
}
}
if img := render(`{"id":"a","type":"file","file":"Other Page/pic one.jpg","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(img, `<img src="/Other%20Page/pic%20one.jpg?w=800"`) {
t.Errorf("image node:\n%s", img)
}
if zip := render(`{"id":"a","type":"file","file":"Other Page/data.zip","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(zip, `class="canvas-card" href="/Other%20Page/data.zip"`) {
t.Errorf("file card node:\n%s", zip)
}
for _, p := range []string{"nope.md", ".secret/x.md", "../../etc/passwd"} {
if out := render(`{"id":"a","type":"file","file":"` + p + `","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(out, "canvas-missing") || strings.Contains(out, "secret") && p != ".secret/x.md" {
t.Errorf("file %q not reported missing:\n%s", p, out)
}
}
link := render(`{"id":"a","type":"link","url":"javascript:alert(1)","x":0,"y":0,"width":1,"height":1}`)
if strings.Contains(link, `href="javascript:`) {
t.Errorf("unsafe link rendered:\n%s", link)
}
named := render(`{"id":"a","type":"link","url":"https://jsoncanvas.org/spec/1.0/","label":"The spec","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(named, `<span class="truncate">The spec</span><span class="muted truncate">jsoncanvas.org</span>`) || !strings.Contains(named, `title="https://jsoncanvas.org/spec/1.0/"`) {
t.Errorf("link with display text:\n%s", named)
}
group := render(`{"id":"a","type":"group","label":"<b>G</b>","color":"#abc","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(group, "&lt;b&gt;G&lt;/b&gt;") || !strings.Contains(group, "--node-color: #abc") {
t.Errorf("group node:\n%s", group)
}
}
func TestCanvasCreate(t *testing.T) {
root := t.TempDir()
h := &handler{root: root}
create := func(name string) *httptest.ResponseRecorder {
form := url.Values{"name": {name}}.Encode()
return canvasRequest(h, "POST", "/New%20Page/?canvas", form, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"HX-Request": "true",
})
}
w := create("My Board")
if w.Code != http.StatusNoContent || w.Header().Get("HX-Redirect") != "/New%20Page/My%20Board.canvas" {
t.Fatalf("create: %d %q %s", w.Code, w.Header().Get("HX-Redirect"), w.Body)
}
raw, err := os.ReadFile(filepath.Join(root, "New Page", "My Board.canvas"))
if err != nil || string(raw) != string(emptyCanvas) {
t.Fatalf("created file: %v %q", err, raw)
}
if w := create("My Board.canvas"); w.Code != http.StatusConflict {
t.Errorf("duplicate: got %d, want 409", w.Code)
}
for _, bad := range []string{"", " ", ".hidden", "a/b", `a\b`} {
if w := create(bad); w.Code != http.StatusBadRequest {
t.Errorf("name %q: got %d, want 400", bad, w.Code)
}
}
}
func TestCanvasListedWithIcon(t *testing.T) {
if fileIcon("Board.canvas") != iconCanvas {
t.Error("canvas files do not get the canvas icon")
}
}
func TestCanvasSubpath(t *testing.T) {
doc := []byte("# LAN Party 2026\nIntro.\n## Teilnehmer\n| Nr. | Name |\n| --- | --- |\n| 1. | Marci |\n")
cases := []struct {
subpath, want string
ok bool
}{
{"", string(doc), true},
// The last section of the file runs to its end.
{"#teilnehmer", "## Teilnehmer\n| Nr. | Name |\n| --- | --- |\n| 1. | Marci |\n", true},
// A heading takes the sections nested under it along.
{"#LAN Party 2026", string(doc), true},
{"#Gäste", "", false},
{"#^block", "", false},
}
for _, c := range cases {
got, ok := canvasSubpath(doc, c.subpath)
if ok != c.ok || string(got) != c.want {
t.Errorf("canvasSubpath(%q) = %q, %v; want %q, %v", c.subpath, got, ok, c.want, c.ok)
}
}
}
func TestSectionSpanEndAtLastSection(t *testing.T) {
sections := splitSections([]byte("# A\n## B\ntext\n"))
if end := secionSpanEnd(sections, 2); end != 3 {
t.Errorf("span of the last section ends at %d, want 3", end)
}
if end := secionSpanEnd(sections, 1); end != 3 {
t.Errorf("span of the top section ends at %d, want 3", end)
}
}
+2
View File
@@ -69,6 +69,8 @@ func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, f
case "view":
view, sortKey, order := readPageSettings(fsPath).viewSettings()
renderDialog(w, "view", struct{ View, Sort, Order string }{view, sortKey, order})
case "canvas":
renderDialog(w, "canvas", nil)
case "delete":
renderDialog(w, "delete", struct{ Path string }{urlPath})
case "move":
+151
View File
@@ -0,0 +1,151 @@
// End-to-end coverage of the canvas view: creating a canvas from a page,
// notes, connections, moving, deleting and the autosave behind all of them.
// Like pages.spec.js, every test works in its own fresh top-level folder.
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
throw err;
});
});
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
// sandbox creates a fresh page, optionally holding Board.canvas with doc.
function sandbox(doc) {
const root = "c" + randomWord(7);
mkdirSync(join(wiki, root), { recursive: true });
writeFileSync(join(wiki, root, "index.md"), "# Canvas host\n");
if (doc) writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(doc));
return root;
}
const readCanvas = (root, name = "Board.canvas") => JSON.parse(readFileSync(join(wiki, root, name), "utf8"));
const node = (page, id) => page.locator(`.canvas-node[data-id="${id}"]`);
function note(id, x, y, text) {
return { id, type: "text", x, y, width: 200, height: 80, text };
}
async function pageAction(page, title) {
const toggle = page.getByRole("button", { name: "ACTIONS" });
if (await toggle.isVisible()) await toggle.click();
await page.locator("header").getByTitle(title).click();
}
test("creates a canvas from a page and writes a note", async ({ page }) => {
const root = sandbox();
await page.goto(`/${root}/`);
await pageAction(page, "New canvas on this page");
await page.locator(".modal").getByPlaceholder("Canvas name").fill("Ideas");
await page.locator(".modal").getByRole("button", { name: "CREATE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Ideas\\.canvas$`));
// The canvas takes the whole shell: no folder tree, no footer.
await expect(page.locator("aside.tree-sidebar")).toHaveCount(0);
await expect(page.locator("footer")).toHaveCount(0);
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
await expect(page.locator(".canvas-node-editor .cm-content")).toBeFocused();
await page.keyboard.type("Hello **world**");
await page.keyboard.press("Escape");
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
await expect.poll(() => readCanvas(root, "Ideas.canvas").nodes.map((n) => n.text)).toEqual(["Hello **world**"]);
await expect(page.locator(".canvas-status")).toHaveText("Saved");
await page.reload();
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
// The page lists its canvas.
await page.goto(`/${root}/`);
await expect(page.locator("main").getByRole("link", { name: "Ideas.canvas" })).toBeVisible();
});
test("an empty new note disappears", async ({ page }) => {
const root = sandbox({ nodes: [], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
await expect(page.locator(".canvas-node")).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(page.locator(".canvas-node")).toHaveCount(0);
});
test("edits a note with the page editor's toolbar", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "plain")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").dblclick();
await expect(page.locator(".canvas-node-toolbar")).toBeVisible();
await page.keyboard.press("Control+End");
await page.keyboard.type(" ");
await page.locator(".canvas-node-toolbar").getByTitle("Bold (B)").click();
await page.keyboard.type("strong");
// Clicking the background closes the editor.
await page.locator(".canvas-viewport").click({ position: { x: 5, y: 300 } });
await expect(page.locator(".canvas-node-toolbar")).toBeHidden();
await expect(node(page, "a").locator("strong")).toHaveText("strong");
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("plain **strong**");
});
test("connects two nodes by dragging a handle", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").click();
const handle = node(page, "a").locator('.canvas-handle[data-side="right"]');
const from = await handle.boundingBox();
const to = await node(page, "b").boundingBox();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(to.x + 20, to.y + to.height / 2, { steps: 5 });
await page.mouse.up();
await expect(page.locator(".canvas-edge")).toHaveCount(1);
await expect
.poll(() => readCanvas(root).edges.map((e) => [e.fromNode, e.fromSide, e.toNode, e.toSide]))
.toEqual([["a", "right", "b", "left"]]);
});
test("moves, undoes and deletes nodes", async ({ page }) => {
const root = sandbox({
nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")],
edges: [{ id: "e", fromNode: "a", toNode: "b" }],
});
await page.goto(`/${root}/Board.canvas`);
const box = await node(page, "a").boundingBox();
await page.mouse.move(box.x + 50, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + 50, box.y + 240, { steps: 5 });
await page.mouse.up();
await expect.poll(() => readCanvas(root).nodes[0].y).toBeGreaterThan(100);
await page.keyboard.press("Control+z");
await expect.poll(() => readCanvas(root).nodes[0].y).toBe(0);
// Deleting a node removes its connections too.
await node(page, "a").click();
await page.keyboard.press("Delete");
await expect(node(page, "a")).toHaveCount(0);
await expect.poll(() => readCanvas(root)).toMatchObject({ nodes: [{ id: "b" }], edges: [] });
});
test("a task checkbox in a note persists", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "- [ ] one\n- [ ] two")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").locator("input.task-checkbox").nth(1).check();
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("- [ ] one\n- [x] two");
});
test("a change made elsewhere stops the autosave", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "A")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
const other = { nodes: [note("z", 0, 0, "from elsewhere")], edges: [] };
writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(other));
await node(page, "a").click();
await page.keyboard.press("ArrowDown");
await expect(page.locator(".canvas-status")).toContainText("Changed elsewhere");
expect(readCanvas(root)).toEqual(other);
});
+61 -2
View File
@@ -34,13 +34,42 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *
if s == nil || s.Type != "fitness" {
return nil
}
q := r.URL.Query()
weightView, weeklyView := fitnessViews(r.URL.Query())
return &specialPage{
Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
Content: renderFitnessDashboard(fsPath, weightView, weeklyView),
SuppressTOC: true,
}
}
// serveFragment answers a chart's controls (htmx GET ?chart=<param> with
// every chart's view state) with just that chart, and points the address bar
// at the canonical dashboard URL so the view stays bookmarkable.
func (f *fitnessHandler) serveFragment(w http.ResponseWriter, r *http.Request, root, fsPath, urlPath string) bool {
q := r.URL.Query()
param := q.Get("chart")
if param == "" {
return false
}
if s := readPageSettings(fsPath); s == nil || s.Type != "fitness" {
return false
}
weightView, weeklyView := fitnessViews(q)
dash := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
for _, c := range dash.Charts {
if c.Param != param {
continue
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Replace-Url", fitnessURL(urlPath, weightView, weeklyView))
if err := fitnessTmpl.ExecuteTemplate(w, "fitnessChart", c); err != nil {
log.Printf("fitness template: %v", err)
}
return true
}
http.Error(w, "Unknown chart: "+param, http.StatusNotFound)
return true
}
// === Time ranges ===
type fitnessRange struct {
@@ -79,6 +108,36 @@ func chartViewFrom(q url.Values, param, fallback string) fitnessView {
}
}
// Default ranges of the two charts.
const (
weightDefaultRange = "3m"
weeklyDefaultRange = "1y"
)
func fitnessViews(q url.Values) (weight, weekly fitnessView) {
return chartViewFrom(q, "weight", weightDefaultRange), chartViewFrom(q, "weekly", weeklyDefaultRange)
}
// fitnessURL is the dashboard URL for the given views; defaults are left out.
func fitnessURL(urlPath string, weight, weekly fitnessView) string {
q := url.Values{}
add := func(param, fallback string, v fitnessView) {
if v.Range != fallback {
q.Set(param, v.Range)
}
if v.Absolute {
q.Set(param+"-absolute", "1")
}
}
add("weight", weightDefaultRange, weight)
add("weekly", weeklyDefaultRange, weekly)
target := pageURL(urlPath)
if len(q) > 0 {
target += "?" + q.Encode()
}
return target
}
func rangeMonths(v string) int {
for _, r := range fitnessRanges {
if r.Value == v {
+54
View File
@@ -1,6 +1,7 @@
package main
import (
"net/http/httptest"
"os"
"strings"
"path/filepath"
@@ -74,3 +75,56 @@ func TestTimeXTicks(t *testing.T) {
}
}
}
func TestFitnessChartFragment(t *testing.T) {
withBerlin(t)
root := t.TempDir()
cals := filepath.Join(root, "Calendars")
fit := filepath.Join(root, "My Fitness")
for _, d := range []string{cals, fit} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
prev := calendarsDir
calendarsDir = cals
t.Cleanup(func() { calendarsDir = prev })
if err := os.WriteFile(filepath.Join(cals, ".page-settings"), []byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(fit, ".page-settings"), []byte("type = fitness\n"), 0644); err != nil {
t.Fatal(err)
}
writeCal(t, cals, "Gewicht", `UID:1
SUMMARY:96 kg
DTSTART;VALUE=DATE:20200101`)
h := &handler{root: root}
get := func(query string, hx bool) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/My%20Fitness/?"+query, nil)
if hx {
r.Header.Set("HX-Request", "true")
}
w := httptest.NewRecorder()
h.serveDir(w, r, "/My Fitness", fit)
return w
}
w := get("chart=weekly&weight=3m&weekly=all&weekly-absolute=1", true)
body := w.Body.String()
if w.Code != 200 || strings.Contains(body, "<html") || strings.Count(body, `class="fitness-chart panel"`) != 1 ||
!strings.Contains(body, `name="weekly"`) || !strings.Contains(body, `value="all" selected`) {
t.Errorf("weekly fragment: %d\n%s", w.Code, body)
}
if got, want := w.Header().Get("HX-Replace-Url"), "/My%20Fitness/?weekly=all&weekly-absolute=1"; got != want {
t.Errorf("HX-Replace-Url = %q, want %q", got, want)
}
if w := get("chart=bogus", true); w.Code != 404 {
t.Errorf("unknown chart: got %d, want 404", w.Code)
}
// Without htmx the parameter is ignored and the full page renders.
if body := get("chart=weekly", false).Body.String(); strings.Count(body, `class="fitness-chart panel"`) != 2 {
t.Errorf("non-htmx request did not render the dashboard:\n%s", body)
}
}
+29 -1
View File
@@ -32,7 +32,7 @@ var tmplFuncs = template.FuncMap{
var (
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/toolbar.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
)
@@ -64,6 +64,14 @@ type pageTypeHandler interface {
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
}
// fragmentHandler is optionally implemented by a pageTypeHandler whose page
// updates part of itself over htmx (e.g. one chart of a dashboard). It
// answers only htmx GETs outside the editor and returns false when the
// request is not for one of its fragments, so the full page renders.
type fragmentHandler interface {
serveFragment(w http.ResponseWriter, r *http.Request, root, fsPath, urlPath string) bool
}
// pageTypeHandlers is the registry. Each type registers itself via init().
var pageTypeHandlers []pageTypeHandler
@@ -233,6 +241,12 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(fsPath)
if err != nil {
if os.IsNotExist(err) {
// A canvas deleted or moved while open: don't let its autosave
// fall through to the page flow below.
if r.Method == http.MethodPost && isCanvasFile(urlPath) {
http.Error(w, "this canvas no longer exists", http.StatusNotFound)
return
}
// Non-existent path: redirect GETs to the canonical slash form so
// the browser URL is consistent, then serve an empty folder page.
// POSTs must not be redirected — the form action has no trailing
@@ -259,6 +273,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
urlPath += "/"
}
h.serveDir(w, r, urlPath, fsPath)
} else if isCanvasFile(fsPath) {
h.serveCanvas(w, r, urlPath, fsPath)
} else {
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so
// their <img src> can stay relative to the page; /_thumb/<abs path>
@@ -306,6 +322,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
return
}
if isHX(r) && !editMode {
for _, ph := range pageTypeHandlers {
if fh, ok := ph.(fragmentHandler); ok && fh.serveFragment(w, r, h.root, fsPath, urlPath) {
return
}
}
}
for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
http.Redirect(w, r, target, http.StatusFound)
@@ -458,6 +482,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleSettings(w, r, urlPath, fsPath)
return
}
if query.Has("canvas") {
h.handleCanvasCreate(w, r, urlPath, fsPath)
return
}
if query.Has("calsync") {
h.handleCalendarSync(w, r, urlPath, fsPath)
return
+52 -5
View File
@@ -7,14 +7,17 @@ import (
"net/url"
"os"
"path"
"regexp"
"sort"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
)
var md goldmark.Markdown
@@ -129,19 +132,61 @@ var (
iconAudio = readIcon("audio")
iconArchive = readIcon("archive")
iconGeneric = readIcon("generic")
iconCanvas = readIcon("canvas")
)
// renderMarkdown converts raw markdown to trusted HTML.
func renderMarkdown(raw []byte) template.HTML {
return template.HTML(rewriteTaskCheckboxes(convertMarkdown(raw, "")))
}
// convertMarkdown renders raw markdown to HTML with the wiki's table styling
// but leaves task checkboxes as goldmark emits them (disabled), so callers
// decide how they toggle.
//
// base, when set, is the URL path (slash-terminated) relative link and embed
// destinations resolve against. Pages leave it empty: the browser resolves
// them against the page URL. Markdown shown somewhere else (a canvas file
// node showing another folder's page) passes that folder.
func convertMarkdown(raw []byte, base string) []byte {
var buf bytes.Buffer
if err := md.Convert(raw, &buf); err != nil {
return ""
doc := md.Parser().Parse(text.NewReader(raw))
if base != "" {
rebaseDestinations(doc, base)
}
if err := md.Renderer().Render(&buf, raw, doc); err != nil {
return nil
}
out := rewriteTaskCheckboxes(buf.Bytes())
// Goldmark emits a bare `<table>`; tag it so it picks up the shared
// .data-table styling with the grid modifier (per-cell borders + header).
out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
return template.HTML(out)
return bytes.ReplaceAll(buf.Bytes(), []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
}
// schemeRe matches a URL that starts with a scheme ("https:", "mailto:").
var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
// rebaseDestinations prefixes every relative link and image destination in
// doc with base. Absolute paths, fragments, queries and URLs with a scheme
// stay as they are.
func rebaseDestinations(doc ast.Node, base string) {
rebase := func(dest []byte) []byte {
if len(dest) == 0 || dest[0] == '/' || dest[0] == '#' || dest[0] == '?' || schemeRe.Match(dest) {
return dest
}
return append([]byte(base), dest...)
}
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n := n.(type) {
case *ast.Link:
n.Destination = rebase(n.Destination)
case *ast.Image:
n.Destination = rebase(n.Destination)
}
return ast.WalkContinue, nil
})
}
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
@@ -301,6 +346,8 @@ func fileIcon(name string) template.HTML {
switch ext {
case ".md", ".pdf":
return iconDoc
case canvasExt:
return iconCanvas
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
return iconImage
case ".mp4", ".mkv", ".avi", ".mov":
+1 -1
View File
@@ -91,7 +91,7 @@ func secionSpanEnd(sections [][]byte, start int) int {
if level == 0 {
return start + 1
}
for i := start + 1; i <= len(sections); i++ {
for i := start + 1; i < len(sections); i++ {
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
return i
}