Compare commits
14
Commits
9fadfda70d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c456e35c76 | ||
|
|
06da56d0a0 | ||
|
|
95002a34ad | ||
|
|
2903986f44 | ||
|
|
7749d7c237 | ||
|
|
aa90a04f91 | ||
|
|
fa90b3baee | ||
|
|
20f0ae5111 | ||
|
|
14fadf096a | ||
|
|
a55a1c8ed4 | ||
|
|
4365fa2567 | ||
|
|
353df29ee6 | ||
|
|
3a3fb3b51f | ||
|
|
465aed47b3 |
@@ -11,7 +11,7 @@ RELEASE_FLAGS := -trimpath -ldflags="-s -w"
|
||||
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
|
||||
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
|
||||
|
||||
.PHONY: deploy companion companion-windows companion-linux companion-release editor
|
||||
.PHONY: deploy companion companion-windows companion-linux companion-release editor e2e
|
||||
|
||||
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist
|
||||
# before `go build .` so embed.FS picks them up.
|
||||
@@ -46,3 +46,8 @@ deploy: companion-release
|
||||
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
||||
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
|
||||
rm datascape-arm
|
||||
|
||||
# Browser end-to-end tests (Playwright driving the system Chrome). Builds the
|
||||
# wiki binary and runs it against a throwaway wiki in e2e/.tmp.
|
||||
e2e:
|
||||
cd e2e && npm install --no-audit --no-fund && npx playwright test
|
||||
|
||||
@@ -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 ``.
|
||||
- **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
@@ -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}}
|
||||
@@ -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}}
|
||||
+13
-4
@@ -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);
|
||||
@@ -200,13 +202,20 @@
|
||||
// leaves the status icon and reveal buttons invisible on mobile.
|
||||
if (isMobileDevice()) return;
|
||||
probeStatus().then(function () {
|
||||
updateFooterIcon();
|
||||
wireFileLinks();
|
||||
wireRevealButton();
|
||||
wireFileRevealButtons();
|
||||
wireFileLinks(); // delegated to document: once
|
||||
applyToPage();
|
||||
// Boosted navigation swaps in a new page without reloading this
|
||||
// script: re-apply to its footer icon and reveal buttons.
|
||||
document.addEventListener('page:swapped', applyToPage);
|
||||
});
|
||||
}
|
||||
|
||||
function applyToPage() {
|
||||
updateFooterIcon();
|
||||
wireRevealButton();
|
||||
wireFileRevealButtons();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
|
||||
@@ -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
@@ -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)">></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
@@ -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; });
|
||||
})();
|
||||
|
||||
@@ -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)">></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}}
|
||||
@@ -1,21 +1,10 @@
|
||||
// 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.
|
||||
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();
|
||||
});
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
@@ -72,3 +61,4 @@ document.addEventListener('pointermove', function (e) {
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = top + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// Site-wide htmx policy. Loaded right after htmx itself.
|
||||
(function () {
|
||||
// htmx wires up swapped-in content (e.g. a dialog's hx-post form) in its
|
||||
// settle step, by default 20ms after the swap: a click or Enter in that
|
||||
// window would submit the form natively. No settle transitions are used,
|
||||
// so settle in the same tick.
|
||||
htmx.config.defaultSettleDelay = 0;
|
||||
|
||||
function boosting() {
|
||||
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 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() && isPageURL(new URL(target, location.href))) {
|
||||
if (window.closeModal) closeModal();
|
||||
htmx.ajax('GET', target, {
|
||||
target: '.shell',
|
||||
swap: 'innerHTML transition:true',
|
||||
headers: { 'HX-Boosted': 'true' },
|
||||
replace: target
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(null, '', target);
|
||||
window.location.reload();
|
||||
},
|
||||
reload: function () { window.location.reload(); }
|
||||
};
|
||||
|
||||
// Failed requests answer with a plain-text reason; show it. A failed
|
||||
// boosted navigation falls back to loading the page normally instead.
|
||||
function fallBack(e) {
|
||||
if (!e.detail.boosted) return false;
|
||||
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
|
||||
return true;
|
||||
}
|
||||
document.addEventListener('htmx:responseError', function (e) {
|
||||
if (fallBack(e)) return;
|
||||
var xhr = e.detail.xhr;
|
||||
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
|
||||
});
|
||||
document.addEventListener('htmx:sendError', function (e) {
|
||||
if (fallBack(e)) return;
|
||||
alert('Network error');
|
||||
});
|
||||
|
||||
// --- Boosted navigation ---------------------------------------------
|
||||
// Outside the editor, links and GET forms to wiki pages load the next page
|
||||
// into .shell instead of replacing the document: the server answers with
|
||||
// the "partial" (title, page actions out-of-band, shell content). The
|
||||
// header stays put, nothing flashes in browsers without cross-document
|
||||
// view transitions, and the swap runs as a same-document view transition.
|
||||
// Back/Forward refetch the partial (hx-history="false" on .shell: no
|
||||
// snapshots that could show a page from before a move or delete).
|
||||
htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
||||
// htmx decides per element whether it is boosted, right after this event.
|
||||
document.addEventListener('htmx:beforeProcessNode', function (e) {
|
||||
var elt = e.target;
|
||||
if (elt.hasAttribute('hx-boost')) return;
|
||||
var page = false;
|
||||
if (elt.tagName === 'A') {
|
||||
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
|
||||
} else if (elt.tagName === 'FORM') {
|
||||
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!page) elt.setAttribute('hx-boost', 'false');
|
||||
});
|
||||
|
||||
function isShell(elt) {
|
||||
return elt && elt.classList && elt.classList.contains('shell');
|
||||
}
|
||||
|
||||
// Scroll positions: the tree rail keeps its position across navigation;
|
||||
// the content column (.center) starts at the top, or where it was when
|
||||
// coming back via Back/Forward.
|
||||
var treeTop = 0;
|
||||
var restoring = false;
|
||||
var centerTops = {};
|
||||
function pageKey() {
|
||||
return location.pathname + location.search;
|
||||
}
|
||||
function rememberTree() {
|
||||
var tree = document.querySelector('.tree-scroll');
|
||||
treeTop = tree ? tree.scrollTop : 0;
|
||||
}
|
||||
document.addEventListener('scroll', function (e) {
|
||||
var t = e.target;
|
||||
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
||||
if (e.detail.boosted) {
|
||||
e.detail.target = document.querySelector('.shell');
|
||||
e.detail.swapOverride = 'innerHTML transition:true';
|
||||
}
|
||||
if (isShell(e.detail.target)) rememberTree();
|
||||
});
|
||||
document.addEventListener('htmx:historyCacheMissLoad', function () {
|
||||
restoring = true;
|
||||
rememberTree();
|
||||
});
|
||||
|
||||
// After a new page is in .shell: restore scroll positions, then tell page
|
||||
// scripts (companion.js, …) to apply themselves to the new content.
|
||||
document.addEventListener('htmx:afterSettle', function (e) {
|
||||
if (!isShell(e.target)) return;
|
||||
var tree = document.querySelector('.tree-scroll');
|
||||
if (tree) {
|
||||
tree.scrollTop = treeTop;
|
||||
var active = tree.querySelector('.tree-row.is-active');
|
||||
if (active) active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
if (restoring) {
|
||||
restoring = false;
|
||||
var center = document.querySelector('.center');
|
||||
if (center) center.scrollTop = centerTops[pageKey()] || 0;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('page:swapped'));
|
||||
});
|
||||
})();
|
||||
@@ -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 |
+64
-39
@@ -8,6 +8,8 @@
|
||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="stylesheet" href="{{asset "style.css"}}" />
|
||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||
<script src="{{asset "hx.js"}}" defer></script>
|
||||
<script src="{{asset "modal.js"}}"></script>
|
||||
<script src="{{asset "global-shortcuts.js"}}"></script>
|
||||
<script src="{{asset "history-nav.js"}}"></script>
|
||||
@@ -16,10 +18,14 @@
|
||||
<script src="{{asset "tree-picker.js"}}"></script>
|
||||
<script src="{{asset "companion.js"}}" defer></script>
|
||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
|
||||
<script src="{{asset "tree-sidebar.js"}}" defer></script>{{end}}
|
||||
{{block "headScripts" .}}{{end}}
|
||||
<script src="{{asset "page/actions.js"}}"></script>
|
||||
<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{/* 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. 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>
|
||||
@@ -35,48 +41,67 @@
|
||||
<span class="dropdown actions">
|
||||
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
||||
<div class="dropdown-menu align-right actions-menu">
|
||||
<div class="actions-special"></div>
|
||||
{{template "headerActions" . }}
|
||||
{{template "page-actions" .}}
|
||||
</div>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell">
|
||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||
<div class="tree-scroll"></div>
|
||||
<script>
|
||||
// Paint the previous page's tree before first render so the rail
|
||||
// doesn't flash empty; tree-sidebar.js swaps in the live tree.
|
||||
(function () {
|
||||
try {
|
||||
var s = JSON.parse(sessionStorage.getItem("tree-snapshot"));
|
||||
if (!s) return;
|
||||
var el = document.currentScript.previousElementSibling;
|
||||
el.innerHTML = s.html;
|
||||
var a = el.querySelector('a.tree-name[href="' + CSS.escape(location.pathname.replace(/\/*$/, "/")) + '"]');
|
||||
if (a) a.parentNode.classList.add("is-active");
|
||||
el.scrollTop = s.top;
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</aside>{{end}}
|
||||
<div class="center">
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
<footer>
|
||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||
{{block "footerExtras" .}}{{end}}
|
||||
<span class="dropdown companion-status" data-companion-status hidden>
|
||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||
</div>
|
||||
<div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
|
||||
{{block "extras" .}}{{end}}
|
||||
<div id="modal-host"></div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{/* The page-specific header actions; the partial swaps them out-of-band
|
||||
(the attribute is inert in a full page). */}}
|
||||
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
|
||||
<div class="actions-special">
|
||||
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
|
||||
</div>
|
||||
{{template "headerActions" .}}
|
||||
</div>{{end}}
|
||||
|
||||
{{define "shell"}}
|
||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
|
||||
<script>
|
||||
// Full page loads: keep the rail's scroll position (before first
|
||||
// paint, so it doesn't jump), then reveal the active row. Boosted
|
||||
// navigation keeps it itself (hx.js); this script re-runs then and
|
||||
// bails out.
|
||||
(function () {
|
||||
if (document.readyState !== "loading") return;
|
||||
var el = document.currentScript.previousElementSibling;
|
||||
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
|
||||
var active = el.querySelector(".tree-row.is-active");
|
||||
if (active) active.scrollIntoView({ block: "nearest" });
|
||||
addEventListener("pagehide", function () {
|
||||
var cur = document.querySelector(".tree-scroll");
|
||||
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</aside>{{end}}
|
||||
<div class="center">
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
<footer>
|
||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||
{{block "footerExtras" .}}{{end}}
|
||||
<span class="dropdown companion-status" data-companion-status hidden>
|
||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* Response to boosted navigation and history restores (hx.js swaps it into
|
||||
.shell): the title, the page actions out-of-band, and the shell's content. */}}
|
||||
{{define "partial"}}<title>{{.Title}}</title>
|
||||
{{template "page-actions" .}}
|
||||
{{template "shell" .}}{{end}}
|
||||
|
||||
+144
-136
@@ -1,120 +1,111 @@
|
||||
// One modal at a time, from either of two sources sharing the same chrome
|
||||
// (backdrop click, Escape, drag by the header, focus restore):
|
||||
// - openModal(opts) builds a modal from JS;
|
||||
// - server-rendered dialogs (assets/page/dialogs.html) that htmx swaps into
|
||||
// #modal-host. Their forms post themselves; a [data-modal-close] element
|
||||
// closes them.
|
||||
(function () {
|
||||
var backdrop = null;
|
||||
var modal = null;
|
||||
var titleEl = null;
|
||||
var bodyEl = null;
|
||||
var cancelBtn = null;
|
||||
var confirmBtn = null;
|
||||
var footerEl = null;
|
||||
var prevFocus = null;
|
||||
var currentOpts = null;
|
||||
var onKeydown = null;
|
||||
var current = null; // { backdrop, prevFocus, onKeydown }
|
||||
var drag = null; // { modal, startX, startY, originX, originY }
|
||||
|
||||
function build() {
|
||||
backdrop = document.createElement('div');
|
||||
// show mounts backdrop (whose child is the .modal) into parent and makes
|
||||
// it the current modal. onKeydown handles keys beyond Escape.
|
||||
function show(backdrop, parent, prevFocus, onKeydown) {
|
||||
var modal = backdrop.querySelector('.modal');
|
||||
var header = modal.querySelector('.panel-header');
|
||||
function onDown(e) { startDrag(e, modal); }
|
||||
header.addEventListener('mousedown', onDown);
|
||||
header.addEventListener('touchstart', onDown, { passive: false });
|
||||
backdrop.addEventListener('mousedown', function (e) {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
backdrop.addEventListener('click', function (e) {
|
||||
if (e.target.closest('[data-modal-close]')) close();
|
||||
});
|
||||
|
||||
var keydown = function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (onKeydown) onKeydown(e);
|
||||
};
|
||||
document.addEventListener('keydown', keydown);
|
||||
current = { backdrop: backdrop, prevFocus: prevFocus, onKeydown: keydown };
|
||||
if (backdrop.parentNode !== parent) parent.appendChild(backdrop);
|
||||
}
|
||||
|
||||
// Dragging switches the modal to fixed positioning so flexbox alignment
|
||||
// doesn't fight us.
|
||||
function startDrag(e, modal) {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var rect = modal.getBoundingClientRect();
|
||||
modal.classList.add('is-dragged');
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.left = rect.left + 'px';
|
||||
modal.style.top = rect.top + 'px';
|
||||
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (!drag) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var m = drag.modal;
|
||||
var maxX = window.innerWidth - m.offsetWidth;
|
||||
var maxY = window.innerHeight - m.offsetHeight;
|
||||
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
|
||||
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
|
||||
}
|
||||
|
||||
function onUp() { drag = null; }
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('touchend', onUp);
|
||||
|
||||
// detach tears down the current modal and returns the element that had
|
||||
// focus before it opened. keepDOM leaves the backdrop in place for a swap
|
||||
// about to replace it: htmx must still find the element that issued the
|
||||
// request in the document.
|
||||
function detach(keepDOM) {
|
||||
if (!current) return null;
|
||||
var c = current;
|
||||
current = null;
|
||||
drag = null;
|
||||
document.removeEventListener('keydown', c.onKeydown);
|
||||
if (!keepDOM && c.backdrop.parentNode) c.backdrop.parentNode.removeChild(c.backdrop);
|
||||
return c.prevFocus;
|
||||
}
|
||||
|
||||
function close() {
|
||||
var toRestore = detach();
|
||||
if (toRestore && toRestore.focus) {
|
||||
try { toRestore.focus(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
var prevFocus = current ? detach() : document.activeElement;
|
||||
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
|
||||
modal = document.createElement('div');
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal panel panel-floating';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'panel-header';
|
||||
titleEl = document.createElement('span');
|
||||
var titleEl = document.createElement('span');
|
||||
titleEl.textContent = opts.title || '';
|
||||
header.appendChild(titleEl);
|
||||
|
||||
bodyEl = document.createElement('div');
|
||||
var bodyEl = document.createElement('div');
|
||||
bodyEl.className = 'panel-body';
|
||||
|
||||
footerEl = document.createElement('div');
|
||||
footerEl.className = 'panel-footer';
|
||||
|
||||
cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(bodyEl);
|
||||
modal.appendChild(footerEl);
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
backdrop.addEventListener('mousedown', function (e) {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
wireDrag(header);
|
||||
cancelBtn.addEventListener('click', close);
|
||||
confirmBtn.addEventListener('click', function () {
|
||||
if (confirmBtn.disabled) return;
|
||||
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
|
||||
currentOpts.confirm.onConfirm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// wireDrag makes the modal draggable by `handle`. Dragging switches the
|
||||
// modal to fixed positioning so flexbox alignment doesn't fight us.
|
||||
function wireDrag(handle) {
|
||||
var dragging = false;
|
||||
var startX, startY, originX, originY;
|
||||
|
||||
function onDown(e) {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var rect = modal.getBoundingClientRect();
|
||||
modal.classList.add('is-dragged');
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.left = rect.left + 'px';
|
||||
modal.style.top = rect.top + 'px';
|
||||
dragging = true;
|
||||
startX = pt.clientX;
|
||||
startY = pt.clientY;
|
||||
originX = rect.left;
|
||||
originY = rect.top;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (!dragging) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var dx = pt.clientX - startX;
|
||||
var dy = pt.clientY - startY;
|
||||
var w = modal.offsetWidth;
|
||||
var h = modal.offsetHeight;
|
||||
var maxX = window.innerWidth - w;
|
||||
var maxY = window.innerHeight - h;
|
||||
var x = Math.max(0, Math.min(maxX, originX + dx));
|
||||
var y = Math.max(0, Math.min(maxY, originY + dy));
|
||||
modal.style.left = x + 'px';
|
||||
modal.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function onUp() { dragging = false; }
|
||||
|
||||
handle.addEventListener('mousedown', onDown);
|
||||
handle.addEventListener('touchstart', onDown, { passive: false });
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('touchend', onUp);
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
if (backdrop && backdrop.parentNode) close();
|
||||
if (!backdrop) build();
|
||||
|
||||
currentOpts = opts;
|
||||
prevFocus = document.activeElement;
|
||||
|
||||
modal.classList.remove('is-dragged');
|
||||
modal.style.position = '';
|
||||
modal.style.left = '';
|
||||
modal.style.top = '';
|
||||
|
||||
titleEl.textContent = opts.title || '';
|
||||
|
||||
bodyEl.textContent = '';
|
||||
if (opts.body instanceof Node) {
|
||||
bodyEl.appendChild(opts.body);
|
||||
} else if (typeof opts.body === 'string') {
|
||||
@@ -124,14 +115,24 @@
|
||||
var confirmOpts = opts.confirm || {};
|
||||
var cancelOpts = opts.cancel || {};
|
||||
|
||||
var confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.textContent = confirmOpts.label || 'OK';
|
||||
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
|
||||
confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
|
||||
confirmBtn.addEventListener('click', function () {
|
||||
if (confirmBtn.disabled) return;
|
||||
if (confirmOpts.onConfirm) confirmOpts.onConfirm();
|
||||
});
|
||||
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.textContent = cancelOpts.label || 'CANCEL';
|
||||
cancelBtn.className = 'btn';
|
||||
cancelBtn.setAttribute('data-modal-close', '');
|
||||
|
||||
footerEl.textContent = '';
|
||||
var footerEl = document.createElement('div');
|
||||
footerEl.className = 'panel-footer';
|
||||
if (opts.swapButtons) {
|
||||
footerEl.appendChild(confirmBtn);
|
||||
footerEl.appendChild(cancelBtn);
|
||||
@@ -140,7 +141,20 @@
|
||||
footerEl.appendChild(confirmBtn);
|
||||
}
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(bodyEl);
|
||||
modal.appendChild(footerEl);
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
var enterConfirms = confirmOpts.enterConfirms !== false;
|
||||
show(backdrop, document.body, prevFocus, function (e) {
|
||||
if (e.key === 'Enter' && enterConfirms) {
|
||||
var tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'TEXTAREA') return;
|
||||
e.preventDefault();
|
||||
if (!confirmBtn.disabled) confirmBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(function () {
|
||||
if (cancelOpts.autofocus) {
|
||||
@@ -156,22 +170,6 @@
|
||||
}
|
||||
}, 0);
|
||||
|
||||
var enterConfirms = confirmOpts.enterConfirms !== false;
|
||||
onKeydown = function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && enterConfirms) {
|
||||
var tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'TEXTAREA') return;
|
||||
e.preventDefault();
|
||||
if (!confirmBtn.disabled) confirmBtn.click();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
return {
|
||||
close: close,
|
||||
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
|
||||
@@ -179,20 +177,30 @@
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!backdrop || !backdrop.parentNode) return;
|
||||
if (onKeydown) {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
onKeydown = null;
|
||||
}
|
||||
backdrop.parentNode.removeChild(backdrop);
|
||||
currentOpts = null;
|
||||
var toRestore = prevFocus;
|
||||
prevFocus = null;
|
||||
if (toRestore && toRestore.focus) {
|
||||
try { toRestore.focus(); } catch (e) {}
|
||||
}
|
||||
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal
|
||||
// is open (e.g. the merge step replacing the move dialog); focus returns
|
||||
// to where it was before the first one opened.
|
||||
var hostPrevFocus = null;
|
||||
// History restores swap without event details; they never target the host.
|
||||
function isHost(e) {
|
||||
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
|
||||
}
|
||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
||||
if (!isHost(e)) return;
|
||||
hostPrevFocus = current ? detach(true) : document.activeElement;
|
||||
});
|
||||
document.addEventListener('htmx:afterSwap', function (e) {
|
||||
if (!isHost(e)) return;
|
||||
var host = e.detail.target;
|
||||
var backdrop = host.querySelector('.modal-backdrop');
|
||||
if (!backdrop) return;
|
||||
show(backdrop, host, hostPrevFocus);
|
||||
var f = backdrop.querySelector('[autofocus]') || backdrop.querySelector('input:not([type=hidden])');
|
||||
if (f) {
|
||||
f.focus();
|
||||
if (f.select) f.select();
|
||||
}
|
||||
});
|
||||
|
||||
window.openModal = open;
|
||||
window.closeModal = close;
|
||||
|
||||
+7
-116
@@ -3,40 +3,6 @@ function encodePickedPath(p) {
|
||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
// postReplace POSTs to action with the optional form body, then loads target
|
||||
// into the current history entry — so the action and its result occupy one
|
||||
// entry instead of two, and back-navigation skips past the stale pre-mutation
|
||||
// snapshot in bfcache. body may be null for empty POSTs.
|
||||
//
|
||||
// We can't just call window.location.replace(target): when target differs from
|
||||
// the current URL only by fragment, the browser updates the URL bar without
|
||||
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
|
||||
// rewrite the current entry's URL via history.replaceState, then reload — the
|
||||
// reload always re-fetches and preserves the (new) URL including its fragment.
|
||||
function navigateReplace(target) {
|
||||
window.history.replaceState(null, '', target);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function postReplace(action, body, target) {
|
||||
var init = { method: 'POST', redirect: 'manual' };
|
||||
if (body) {
|
||||
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
init.body = body;
|
||||
}
|
||||
fetch(action, init).then(function (res) {
|
||||
if (res.type === 'opaqueredirect' || res.ok) {
|
||||
navigateReplace(target);
|
||||
return;
|
||||
}
|
||||
return res.text().then(function (msg) {
|
||||
alert(msg || ('Request failed (' + res.status + ')'));
|
||||
});
|
||||
}).catch(function () {
|
||||
alert('Network error');
|
||||
});
|
||||
}
|
||||
|
||||
function promptPageName(title, initial, confirmLabel, onName) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
@@ -75,45 +41,10 @@ function newPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// submitMove POSTs a move and navigates on success. When the server reports
|
||||
// the destination folder already exists but can be merged (the source carries
|
||||
// a page and the destination has none), it asks the user to confirm and
|
||||
// retries the same move with &merge=1.
|
||||
function submitMove(action, target) {
|
||||
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
|
||||
if (res.type === 'opaqueredirect' || res.ok) {
|
||||
navigateReplace(target);
|
||||
return;
|
||||
}
|
||||
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
|
||||
openModal({
|
||||
title: 'Merge folders?',
|
||||
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
|
||||
confirm: {
|
||||
label: 'MERGE',
|
||||
onConfirm: function () {
|
||||
closeModal();
|
||||
submitMove(action + '&merge=1', target);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
return res.text().then(function (msg) {
|
||||
alert(msg || ('Request failed (' + res.status + ')'));
|
||||
});
|
||||
}).catch(function () {
|
||||
alert('Network error');
|
||||
});
|
||||
}
|
||||
|
||||
function movePage() {
|
||||
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||
if (!current) return;
|
||||
var segs = current.split('/').filter(Boolean);
|
||||
var currentName = segs[segs.length - 1] || '';
|
||||
var parent = '/' + segs.slice(0, -1).join('/');
|
||||
if (parent === '/') parent = '/';
|
||||
var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
|
||||
|
||||
openTreePicker({
|
||||
title: 'Move — new parent?',
|
||||
@@ -123,53 +54,13 @@ function movePage() {
|
||||
hideFiles: true,
|
||||
confirmLabel: 'NEXT',
|
||||
onSelect: function (newParent) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'input';
|
||||
input.placeholder = 'Page name';
|
||||
input.value = currentName;
|
||||
|
||||
openModal({
|
||||
title: 'Move — new name?',
|
||||
body: input,
|
||||
confirm: {
|
||||
label: 'MOVE',
|
||||
onConfirm: function () {
|
||||
var name = input.value.trim();
|
||||
if (!name) return;
|
||||
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||
var action = window.location.pathname + '?move=' +
|
||||
encodeURIComponent(dest);
|
||||
var target = encodePickedPath(dest) + '/';
|
||||
closeModal();
|
||||
submitMove(action, target);
|
||||
}
|
||||
}
|
||||
// The server renders the name step and handles what follows
|
||||
// (merge confirmation, errors, redirect).
|
||||
closeModal();
|
||||
htmx.ajax('GET', window.location.pathname + '?dialog=move', {
|
||||
target: '#modal-host',
|
||||
values: { parent: newParent }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deletePage() {
|
||||
var decodedPath = decodeURIComponent(window.location.pathname);
|
||||
|
||||
openModal({
|
||||
title: 'Delete page',
|
||||
body: 'Delete ' + decodedPath + ' and everything inside it?',
|
||||
confirm: {
|
||||
label: 'DELETE',
|
||||
danger: true,
|
||||
enterConfirms: false,
|
||||
onConfirm: function () {
|
||||
var p = window.location.pathname.replace(/\/+$/, '');
|
||||
var idx = p.lastIndexOf('/');
|
||||
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
|
||||
closeModal();
|
||||
postReplace(window.location.pathname + '?delete=1', null, parent);
|
||||
}
|
||||
},
|
||||
cancel: { autofocus: true },
|
||||
swapButtons: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+20
-14
@@ -1,15 +1,21 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var aside = document.querySelector("aside.sidebar");
|
||||
var specials = document.querySelector(".actions-special");
|
||||
if (!aside || !aside.children.length || !specials) return;
|
||||
// Runs on DOMContentLoaded for a full page load, and right away when this
|
||||
// script arrives with a boosted navigation (the document is loaded then).
|
||||
(function () {
|
||||
function run() {
|
||||
var aside = document.querySelector("aside.sidebar");
|
||||
var specials = document.querySelector(".actions-special");
|
||||
if (!aside || !aside.children.length || !specials) return;
|
||||
|
||||
var item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "btn btn-block";
|
||||
item.title = "Contents";
|
||||
item.textContent = "CONTENTS";
|
||||
item.addEventListener("click", function () {
|
||||
if (typeof openOverlay === "function") openOverlay(aside);
|
||||
});
|
||||
specials.appendChild(item);
|
||||
});
|
||||
var item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "btn btn-block";
|
||||
item.title = "Contents";
|
||||
item.textContent = "CONTENTS";
|
||||
item.addEventListener("click", function () {
|
||||
if (typeof openOverlay === "function") openOverlay(aside);
|
||||
});
|
||||
specials.appendChild(item);
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||
else run();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{{/* Server-rendered dialogs, fetched with ?dialog=<name> and swapped into
|
||||
#modal-host by htmx; modal.js supplies the chrome (backdrop, Escape, drag,
|
||||
focus). Each dialog is a form that posts the action it offers, so the client
|
||||
never builds action URLs itself. Responses to those posts either redirect
|
||||
(HX-Redirect), replace the dialog with the next step (e.g. the merge
|
||||
confirmation), or fail with a plain-text error. */}}
|
||||
|
||||
{{define "delete"}}<div class="modal-backdrop">
|
||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?delete=1" hx-target="#modal-host">
|
||||
<div class="panel-header"><span id="modal-title">Delete page</span></div>
|
||||
<div class="panel-body">Delete {{.Path}} and everything inside it?</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn danger" type="submit">DELETE</button>
|
||||
<button class="btn" type="button" data-modal-close autofocus>CANCEL</button>
|
||||
</div>
|
||||
</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>
|
||||
<div class="panel-body">
|
||||
<input type="hidden" name="parent" value="{{.Parent}}" />
|
||||
<input class="input" type="text" name="name" value="{{.Name}}" placeholder="Page name" required autofocus />
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
||||
<button class="btn" type="submit">MOVE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>{{end}}
|
||||
|
||||
{{define "merge"}}<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">Merge folders?</span></div>
|
||||
<div class="panel-body">
|
||||
The destination folder already exists but has no page of its own. Merge this page and its contents into it?
|
||||
<input type="hidden" name="move" value="{{.Dest}}" />
|
||||
<input type="hidden" name="merge" value="1" />
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
||||
<button class="btn" type="submit" autofocus>MERGE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>{{end}}
|
||||
|
||||
{{define "view"}}<div class="modal-backdrop">
|
||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?settings" hx-target="#modal-host">
|
||||
<div class="panel-header"><span id="modal-title">View settings</span></div>
|
||||
<div class="panel-body col">
|
||||
<fieldset class="col gap-1">
|
||||
<legend class="caption">View style</legend>
|
||||
<div class="row gap-1 segmented">
|
||||
<label class="btn"><input type="radio" name="view" value="list"{{if eq .View "list"}} checked{{end}} />List</label>
|
||||
<label class="btn"><input type="radio" name="view" value="thumbnail"{{if eq .View "thumbnail"}} checked{{end}} />Thumbnail</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<label class="col gap-1">
|
||||
<span class="caption">Sort by</span>
|
||||
<select class="input" name="sort">
|
||||
<option value="name"{{if eq .Sort "name"}} selected{{end}}>Name</option>
|
||||
<option value="modified"{{if eq .Sort "modified"}} selected{{end}}>Modified</option>
|
||||
<option value="size"{{if eq .Sort "size"}} selected{{end}}>Size</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="col gap-1">
|
||||
<legend class="caption">Order</legend>
|
||||
<div class="row gap-1 segmented">
|
||||
<label class="btn"><input type="radio" name="order" value="asc"{{if eq .Order "asc"}} checked{{end}} />Asc</label>
|
||||
<label class="btn"><input type="radio" name="order" value="desc"{{if eq .Order "desc"}} checked{{end}} />Desc</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
||||
<button class="btn" type="submit" autofocus>SAVE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>{{end}}
|
||||
@@ -1,11 +1,10 @@
|
||||
{{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
|
||||
|
||||
{{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>
|
||||
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
|
||||
<button class="btn btn-block danger" hx-get="?dialog=delete" hx-target="#modal-host" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
|
||||
{{end}}
|
||||
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
|
||||
{{end}}{{end}}
|
||||
@@ -21,7 +20,7 @@
|
||||
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
|
||||
{{end}}
|
||||
{{if .Entries}}
|
||||
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
||||
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" hx-get="?dialog=view" hx-target="#modal-host" title="View & sorting">view</button>{{end}}</h2>
|
||||
{{if eq .View "thumbnail"}}
|
||||
<div class="thumb-grid">
|
||||
{{range .Entries}}
|
||||
@@ -44,12 +43,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if .CanEdit}}<script src="{{asset "page/view-settings.js"}}"></script>{{end}}
|
||||
{{end}}
|
||||
{{if or .Content .SpecialContent}}
|
||||
<script src="{{asset "page/content.js"}}"></script>
|
||||
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
|
||||
<script src="{{asset "page/tasks.js"}}"></script>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<script src="{{asset "page/sections.js"}}"></script>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
(function () {
|
||||
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
|
||||
cb.addEventListener('change', function () {
|
||||
var idx = cb.dataset.taskIndex;
|
||||
var checked = cb.checked;
|
||||
cb.disabled = true;
|
||||
fetch(window.location.pathname + '?toggle=' + idx, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'checked=' + checked
|
||||
}).then(function (res) {
|
||||
if (!res.ok) {
|
||||
cb.checked = !checked;
|
||||
alert('Failed to save task state (' + res.status + ')');
|
||||
}
|
||||
}).catch(function () {
|
||||
cb.checked = !checked;
|
||||
alert('Failed to save task state');
|
||||
}).finally(function () {
|
||||
cb.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
+33
-27
@@ -1,32 +1,38 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var content = document.querySelector("main");
|
||||
if (!content) return;
|
||||
// Runs on DOMContentLoaded for a full page load, and right away when this
|
||||
// script arrives with a boosted navigation (the document is loaded then).
|
||||
(function () {
|
||||
function run() {
|
||||
var content = document.querySelector("main");
|
||||
if (!content) return;
|
||||
|
||||
var headings = content.querySelectorAll("h2, h3, h4");
|
||||
if (headings.length < 2) return;
|
||||
var headings = content.querySelectorAll("h2, h3, h4");
|
||||
if (headings.length < 2) return;
|
||||
|
||||
var nav = document.createElement("nav");
|
||||
nav.className = "toc panel";
|
||||
var nav = document.createElement("nav");
|
||||
nav.className = "toc panel";
|
||||
|
||||
var header = document.createElement("div");
|
||||
header.className = "panel-header";
|
||||
header.textContent = "Contents";
|
||||
nav.appendChild(header);
|
||||
var header = document.createElement("div");
|
||||
header.className = "panel-header";
|
||||
header.textContent = "Contents";
|
||||
nav.appendChild(header);
|
||||
|
||||
var list = document.createElement("ul");
|
||||
headings.forEach(function (h) {
|
||||
if (!h.id) return;
|
||||
var li = document.createElement("li");
|
||||
li.className = "toc-" + h.tagName.toLowerCase();
|
||||
var a = document.createElement("a");
|
||||
a.href = "#" + h.id;
|
||||
var clone = h.cloneNode(true);
|
||||
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
|
||||
a.textContent = clone.textContent.trim();
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
});
|
||||
nav.appendChild(list);
|
||||
var list = document.createElement("ul");
|
||||
headings.forEach(function (h) {
|
||||
if (!h.id) return;
|
||||
var li = document.createElement("li");
|
||||
li.className = "toc-" + h.tagName.toLowerCase();
|
||||
var a = document.createElement("a");
|
||||
a.href = "#" + h.id;
|
||||
var clone = h.cloneNode(true);
|
||||
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
|
||||
a.textContent = clone.textContent.trim();
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
});
|
||||
nav.appendChild(list);
|
||||
|
||||
content.insertBefore(nav, content.firstChild);
|
||||
});
|
||||
content.insertBefore(nav, content.firstChild);
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||
else run();
|
||||
})();
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// View-settings modal: lets the user pick the folder listing's view style,
|
||||
// sort key, and order, then persists them by POSTing to the folder with
|
||||
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
|
||||
function openViewSettings() {
|
||||
var btn = document.getElementById('view-settings-btn');
|
||||
var state = {
|
||||
view: (btn && btn.dataset.view) || 'list',
|
||||
sort: (btn && btn.dataset.sort) || 'name',
|
||||
order: (btn && btn.dataset.order) || 'asc'
|
||||
};
|
||||
|
||||
// segmented builds a row of mutually-exclusive .btn toggles bound to a
|
||||
// single state key, marking the current choice with .is-active.
|
||||
function segmented(key, options) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'row gap-1';
|
||||
options.forEach(function (opt) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn';
|
||||
b.textContent = opt.label;
|
||||
if (state[key] === opt.value) b.classList.add('is-active');
|
||||
b.addEventListener('click', function () {
|
||||
state[key] = opt.value;
|
||||
wrap.querySelectorAll('button').forEach(function (x) {
|
||||
x.classList.remove('is-active');
|
||||
});
|
||||
b.classList.add('is-active');
|
||||
});
|
||||
wrap.appendChild(b);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function field(labelText, control) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'col gap-1';
|
||||
var label = document.createElement('span');
|
||||
label.className = 'caption';
|
||||
label.textContent = labelText;
|
||||
row.appendChild(label);
|
||||
row.appendChild(control);
|
||||
return row;
|
||||
}
|
||||
|
||||
var sortSelect = document.createElement('select');
|
||||
sortSelect.className = 'input';
|
||||
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = o[0];
|
||||
opt.textContent = o[1];
|
||||
if (state.sort === o[0]) opt.selected = true;
|
||||
sortSelect.appendChild(opt);
|
||||
});
|
||||
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'col';
|
||||
body.appendChild(field('View style', segmented('view', [
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'thumbnail', label: 'Thumbnail' }
|
||||
])));
|
||||
body.appendChild(field('Sort by', sortSelect));
|
||||
body.appendChild(field('Order', segmented('order', [
|
||||
{ value: 'asc', label: 'Asc' },
|
||||
{ value: 'desc', label: 'Desc' }
|
||||
])));
|
||||
|
||||
openModal({
|
||||
title: 'View settings',
|
||||
body: body,
|
||||
confirm: {
|
||||
label: 'SAVE',
|
||||
onConfirm: function () {
|
||||
var action = window.location.pathname + '?settings';
|
||||
var formBody = 'view=' + encodeURIComponent(state.view) +
|
||||
'&sort=' + encodeURIComponent(state.sort) +
|
||||
'&order=' + encodeURIComponent(state.order);
|
||||
var target = window.location.pathname;
|
||||
closeModal();
|
||||
postReplace(action, formBody, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+10
-5
@@ -45,17 +45,22 @@
|
||||
}
|
||||
|
||||
// Reading mode: keep the latest scroll fraction recorded (throttled to one
|
||||
// write per frame) so it is ready the moment the editor opens.
|
||||
// write per frame) so it is ready the moment the editor opens. Boosted
|
||||
// navigation replaces .center and changes the page without a reload, so
|
||||
// listen on document and resolve both when writing.
|
||||
var pending = false;
|
||||
center.addEventListener('scroll', function () {
|
||||
if (pending) return;
|
||||
document.addEventListener('scroll', function (e) {
|
||||
if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
|
||||
pending = true;
|
||||
requestAnimationFrame(function () {
|
||||
pending = false;
|
||||
center = document.querySelector('.center');
|
||||
if (!center) return;
|
||||
var m = maxScroll();
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||
sessionStorage.setItem('scrollsync:' + location.pathname,
|
||||
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||
} catch (e) {}
|
||||
});
|
||||
}, { passive: true });
|
||||
}, { capture: true, passive: true });
|
||||
})();
|
||||
|
||||
+73
-246
@@ -1,63 +1,18 @@
|
||||
// search-suggest.js — instant typeahead dropdown.
|
||||
//
|
||||
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
|
||||
// search box and the editor's "Insert link" modal. Owns: debounced fetching,
|
||||
// request ordering, DOM creation, keyboard handling, open/close lifecycle.
|
||||
// search box and the editor's "Insert link" modal. Fetching is htmx: the input
|
||||
// requests /_search as you type (debounced; a newer request replaces an
|
||||
// in-flight one) and the server renders the rows (assets/search/suggest.html).
|
||||
// This file owns the dropdown's open/close lifecycle and keyboard handling.
|
||||
//
|
||||
// opts:
|
||||
// onPick(result) — called when the user selects a row
|
||||
// onShowAll(query) — optional; called when the footer row activates
|
||||
// onPick({name}) — optional; called when the user selects a row.
|
||||
// Without it rows are plain links and navigate.
|
||||
// showFooter (bool) — show the "Show all N matches" footer row
|
||||
// container (Element) — optional parent (defaults to inputEl.parentNode)
|
||||
(function () {
|
||||
var DEBOUNCE_MS = 100;
|
||||
var MIN_QUERY_LEN = 2;
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
||||
}
|
||||
|
||||
// highlight bolds the substring spans in `name` that match any of the
|
||||
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
|
||||
// Returns a safe HTML string.
|
||||
function highlight(name, tokens) {
|
||||
if (!tokens.length) return escapeHTML(name);
|
||||
var lower = name.toLowerCase();
|
||||
var spans = [];
|
||||
tokens.forEach(function (t) {
|
||||
if (!t) return;
|
||||
var idx = lower.indexOf(t);
|
||||
if (idx >= 0) spans.push([idx, idx + t.length]);
|
||||
});
|
||||
if (!spans.length) return escapeHTML(name);
|
||||
spans.sort(function (a, b) { return a[0] - b[0]; });
|
||||
var merged = [spans[0].slice()];
|
||||
for (var i = 1; i < spans.length; i++) {
|
||||
var last = merged[merged.length - 1];
|
||||
if (spans[i][0] <= last[1]) {
|
||||
last[1] = Math.max(last[1], spans[i][1]);
|
||||
} else {
|
||||
merged.push(spans[i].slice());
|
||||
}
|
||||
}
|
||||
var out = '';
|
||||
var cursor = 0;
|
||||
merged.forEach(function (sp) {
|
||||
out += escapeHTML(name.slice(cursor, sp[0]));
|
||||
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
|
||||
cursor = sp[1];
|
||||
});
|
||||
out += escapeHTML(name.slice(cursor));
|
||||
return out;
|
||||
}
|
||||
var seq = 0;
|
||||
|
||||
function attachSuggestions(inputEl, opts) {
|
||||
if (!inputEl) return;
|
||||
@@ -68,237 +23,116 @@
|
||||
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'suggest-dropdown';
|
||||
dropdown.id = 'suggest-' + (++seq);
|
||||
host.appendChild(dropdown);
|
||||
|
||||
function makeRow(cls, tabbable) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = cls;
|
||||
if (tabbable) tr.setAttribute('tabindex', '0');
|
||||
var td = document.createElement('td');
|
||||
tr.appendChild(td);
|
||||
return { tr: tr, td: td };
|
||||
// htmx sends the input's value under its name.
|
||||
if (!inputEl.name) inputEl.name = 'q';
|
||||
inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
|
||||
inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
|
||||
inputEl.setAttribute('hx-sync', 'this:replace');
|
||||
inputEl.setAttribute('hx-target', '#' + dropdown.id);
|
||||
htmx.process(inputEl);
|
||||
|
||||
var activeIdx = -1;
|
||||
|
||||
function rows() {
|
||||
return dropdown.querySelectorAll('.suggest-row');
|
||||
}
|
||||
|
||||
var state = {
|
||||
results: [],
|
||||
total: 0,
|
||||
query: '',
|
||||
activeIdx: -1,
|
||||
open: false,
|
||||
reqSeq: 0,
|
||||
debounceTimer: null,
|
||||
blurTimer: null,
|
||||
};
|
||||
|
||||
function rowCount() {
|
||||
var n = state.results.length;
|
||||
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
|
||||
return 0; // "no matches" row is non-interactive
|
||||
}
|
||||
if (opts.showFooter && state.total > state.results.length) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
function isFooterIdx(idx) {
|
||||
return opts.showFooter
|
||||
&& state.total > state.results.length
|
||||
&& idx === state.results.length;
|
||||
}
|
||||
|
||||
function render() {
|
||||
dropdown.textContent = '';
|
||||
if (!state.open) {
|
||||
dropdown.classList.remove('is-open');
|
||||
return;
|
||||
}
|
||||
var table = document.createElement('table');
|
||||
table.className = 'data-table';
|
||||
var tbody = document.createElement('tbody');
|
||||
table.appendChild(tbody);
|
||||
|
||||
var tokens = tokenize(state.query);
|
||||
if (state.results.length === 0) {
|
||||
var empty = makeRow('is-empty', false);
|
||||
empty.td.textContent = 'No matches';
|
||||
tbody.appendChild(empty.tr);
|
||||
} else {
|
||||
state.results.forEach(function (r, i) {
|
||||
var row = makeRow('suggest-row', true);
|
||||
row.tr.setAttribute('data-idx', String(i));
|
||||
var nameEl = document.createElement('span');
|
||||
nameEl.className = 'suggest-name';
|
||||
nameEl.innerHTML = highlight(r.name, tokens);
|
||||
var pathEl = document.createElement('span');
|
||||
pathEl.className = 'suggest-path';
|
||||
pathEl.textContent = '/' + r.path;
|
||||
row.td.appendChild(nameEl);
|
||||
row.td.appendChild(pathEl);
|
||||
if (i === state.activeIdx) row.tr.classList.add('is-active');
|
||||
row.tr.addEventListener('mousedown', function (e) {
|
||||
// mousedown (not click) so the input doesn't blur-close
|
||||
// the dropdown before the pick handler fires.
|
||||
e.preventDefault();
|
||||
pick(i);
|
||||
});
|
||||
tbody.appendChild(row.tr);
|
||||
});
|
||||
if (opts.showFooter && state.total > state.results.length) {
|
||||
var footer = makeRow('suggest-row suggest-footer', true);
|
||||
footer.td.textContent = 'Show all ' + state.total + ' matches';
|
||||
var footerIdx = state.results.length;
|
||||
if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active');
|
||||
footer.tr.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
pickFooter();
|
||||
});
|
||||
tbody.appendChild(footer.tr);
|
||||
}
|
||||
}
|
||||
dropdown.appendChild(table);
|
||||
dropdown.classList.add('is-open');
|
||||
}
|
||||
|
||||
function pick(idx) {
|
||||
var r = state.results[idx];
|
||||
if (!r) return;
|
||||
close();
|
||||
if (opts.onPick) opts.onPick(r);
|
||||
}
|
||||
|
||||
function pickFooter() {
|
||||
close();
|
||||
if (opts.onShowAll) {
|
||||
opts.onShowAll(state.query);
|
||||
} else if (inputEl.form) {
|
||||
inputEl.form.submit();
|
||||
} else {
|
||||
window.location.href = '/?q=' + encodeURIComponent(state.query);
|
||||
}
|
||||
function isOpen() {
|
||||
return dropdown.classList.contains('is-open');
|
||||
}
|
||||
|
||||
function open() {
|
||||
state.open = true;
|
||||
render();
|
||||
if (dropdown.firstElementChild) dropdown.classList.add('is-open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.open = false;
|
||||
state.activeIdx = -1;
|
||||
render();
|
||||
dropdown.classList.remove('is-open');
|
||||
setActive(-1);
|
||||
}
|
||||
|
||||
function fetchResults(query) {
|
||||
var seq = ++state.reqSeq;
|
||||
fetch('/_search?q=' + encodeURIComponent(query), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(function (resp) {
|
||||
if (seq !== state.reqSeq) return; // stale
|
||||
state.results = resp.results || [];
|
||||
state.total = resp.total || 0;
|
||||
state.query = resp.query || query;
|
||||
state.activeIdx = -1;
|
||||
open();
|
||||
}).catch(function () {
|
||||
if (seq !== state.reqSeq) return;
|
||||
state.results = [];
|
||||
state.total = 0;
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
var q = inputEl.value.trim();
|
||||
state.query = q;
|
||||
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
||||
if (q.length < MIN_QUERY_LEN) {
|
||||
state.reqSeq++; // invalidate any in-flight response
|
||||
state.results = [];
|
||||
state.total = 0;
|
||||
close();
|
||||
return;
|
||||
function setActive(idx) {
|
||||
var rs = rows();
|
||||
if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
|
||||
activeIdx = idx;
|
||||
if (rs[idx]) {
|
||||
rs[idx].classList.add('is-active');
|
||||
try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
}
|
||||
state.debounceTimer = setTimeout(function () {
|
||||
fetchResults(q);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function moveActive(delta) {
|
||||
var n = rowCount();
|
||||
var n = rows().length;
|
||||
if (n === 0) return;
|
||||
var next = state.activeIdx + delta;
|
||||
var next = activeIdx + delta;
|
||||
if (next < 0) next = n - 1;
|
||||
if (next >= n) next = 0;
|
||||
state.activeIdx = next;
|
||||
render();
|
||||
// Keep the active row in view.
|
||||
var active = dropdown.querySelector('tr.is-active');
|
||||
if (active && active.scrollIntoView) {
|
||||
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
setActive(next);
|
||||
}
|
||||
|
||||
// pick activates a row: onPick for data rows when given, otherwise
|
||||
// (and always for the footer) the row's link.
|
||||
function pick(row) {
|
||||
close();
|
||||
if (opts.onPick && !row.classList.contains('suggest-footer')) {
|
||||
opts.onPick({ name: row.dataset.name });
|
||||
} else {
|
||||
row.querySelector('a').click();
|
||||
}
|
||||
}
|
||||
|
||||
function activateCurrent() {
|
||||
if (state.activeIdx < 0) return false;
|
||||
if (isFooterIdx(state.activeIdx)) {
|
||||
pickFooter();
|
||||
return true;
|
||||
}
|
||||
pick(state.activeIdx);
|
||||
return true;
|
||||
}
|
||||
|
||||
inputEl.addEventListener('input', onInput);
|
||||
inputEl.addEventListener('focus', function () {
|
||||
if (state.blurTimer) {
|
||||
clearTimeout(state.blurTimer);
|
||||
state.blurTimer = null;
|
||||
}
|
||||
if (inputEl.value.trim().length >= MIN_QUERY_LEN
|
||||
&& (state.results.length || state.query)) {
|
||||
dropdown.addEventListener('htmx:afterSwap', function () {
|
||||
activeIdx = -1;
|
||||
if (document.activeElement === inputEl && dropdown.firstElementChild) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
});
|
||||
inputEl.addEventListener('blur', function () {
|
||||
// Delay so click/mousedown on a row still resolves.
|
||||
state.blurTimer = setTimeout(close, 150);
|
||||
|
||||
// mousedown keeps focus in the input so blur doesn't close the
|
||||
// dropdown before the click lands.
|
||||
dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
var row = e.target.closest('.suggest-row');
|
||||
if (!row) return;
|
||||
if (opts.onPick && !row.classList.contains('suggest-footer')) {
|
||||
e.preventDefault();
|
||||
pick(row);
|
||||
} else {
|
||||
close(); // the link navigates
|
||||
}
|
||||
});
|
||||
|
||||
inputEl.addEventListener('focus', open);
|
||||
inputEl.addEventListener('blur', close);
|
||||
inputEl.addEventListener('keydown', function (e) {
|
||||
if (!isOpen()) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (!state.open) return;
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (!state.open) return;
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Escape') {
|
||||
if (!state.open) return;
|
||||
e.preventDefault();
|
||||
close();
|
||||
} else if (e.key === 'Enter') {
|
||||
if (state.open && state.activeIdx >= 0) {
|
||||
// Without an active row: native submit (full results page).
|
||||
var row = rows()[activeIdx];
|
||||
if (row) {
|
||||
e.preventDefault();
|
||||
activateCurrent();
|
||||
pick(row);
|
||||
}
|
||||
// else: native form submit behaviour (full results page)
|
||||
} else if (e.key === 'Tab') {
|
||||
if (!state.open || rowCount() === 0) return;
|
||||
if (rows().length === 0) return;
|
||||
e.preventDefault();
|
||||
moveActive(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside the host closes the dropdown.
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (!state.open) return;
|
||||
if (host.contains(e.target)) return;
|
||||
close();
|
||||
});
|
||||
|
||||
return {
|
||||
close: close,
|
||||
destroy: function () {
|
||||
@@ -310,14 +144,7 @@
|
||||
|
||||
window.attachSuggestions = attachSuggestions;
|
||||
|
||||
// Auto-bind to the header search input. Header search submits the form
|
||||
// for the "show all" action; we route to a navigate-on-pick handler.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.querySelector('header .search-input');
|
||||
if (!input) return;
|
||||
attachSuggestions(input, {
|
||||
showFooter: true,
|
||||
onPick: function (r) { window.location.href = r.url; },
|
||||
});
|
||||
attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
|
||||
});
|
||||
})();
|
||||
|
||||
+21
-12
@@ -52,19 +52,19 @@ function createSearchPage(name) {
|
||||
});
|
||||
}
|
||||
|
||||
// The "create page" link on results pages; delegated, since boosted
|
||||
// navigation swaps the results in without a new DOMContentLoaded.
|
||||
document.addEventListener('click', function (e) {
|
||||
var createLink = e.target.closest && e.target.closest('[data-create-page]');
|
||||
if (!createLink) return;
|
||||
e.preventDefault();
|
||||
createSearchPage(createLink.getAttribute('data-create-page'));
|
||||
});
|
||||
|
||||
// Focus the search input on results pages so Tab steps directly into the
|
||||
// first match — the input sits immediately before the results in DOM order,
|
||||
// so the natural tab sequence is input → first result → next, …
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
var createLink = document.querySelector('[data-create-page]');
|
||||
if (createLink) {
|
||||
createLink.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
createSearchPage(createLink.getAttribute('data-create-page'));
|
||||
});
|
||||
}
|
||||
|
||||
// Focus the search input on results pages so Tab steps directly into the
|
||||
// first match — the input sits immediately before the results in DOM
|
||||
// order, so the natural tab sequence is input → first result → next, …
|
||||
var input = document.querySelector('.search-input');
|
||||
if (input && input.value) {
|
||||
input.focus();
|
||||
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
try { input.setSelectionRange(end, end); } catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
// The header (and its search box) survives boosted navigation: show the
|
||||
// current page's query, as a full load would.
|
||||
document.addEventListener('page:swapped', function () {
|
||||
var input = document.querySelector('.search-input');
|
||||
if (input && document.activeElement !== input) {
|
||||
input.value = new URLSearchParams(location.search).get('q') || '';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
||||
|
||||
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{/* Search typeahead dropdown (/_search, htmx requests). Rows are links, so a
|
||||
pick in the header is plain navigation; the editor's link dialog intercepts
|
||||
the click and reads data-name. Empty output closes the dropdown. */}}
|
||||
{{define "suggest"}}{{if .Show}}<table class="data-table"><tbody>
|
||||
{{range .Results}}<tr class="suggest-row" data-name="{{.Name}}"><td><a class="suggest-link" href="{{.URL}}" tabindex="-1"><span class="suggest-name">{{.Highlighted}}</span><span class="suggest-path">/{{.Path}}</span></a></td></tr>
|
||||
{{else}}<tr class="is-empty"><td>No matches</td></tr>
|
||||
{{end}}{{if .AllURL}}<tr class="suggest-row suggest-footer"><td><a class="suggest-link" href="{{.AllURL}}" tabindex="-1">Show all {{.Total}} matches</a></td></tr>
|
||||
{{end}}</tbody></table>{{end}}{{end}}
|
||||
+346
-8
@@ -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
|
||||
@@ -241,6 +250,20 @@ footer {
|
||||
.danger { color: var(--danger); }
|
||||
.danger:hover { color: var(--danger-hover); }
|
||||
.btn.is-active { color: var(--primary-hover); }
|
||||
/* Segmented choice: radios styled as .btn toggles. The label is the button;
|
||||
the input stays in the accessibility tree and keyboard order. */
|
||||
.segmented input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
}
|
||||
.segmented .btn { position: relative; }
|
||||
.segmented .btn:has(input:checked) { color: var(--primary-hover); }
|
||||
.segmented .btn:has(input:focus-visible) { outline: 1px solid var(--secondary); }
|
||||
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
legend { padding: 0; margin-bottom: var(--space-1); } /* not a flex item: .gap-* skips it */
|
||||
|
||||
/* === Form controls ===
|
||||
.input baseline is shared by search-input, modal inputs, and the editor
|
||||
@@ -411,6 +434,8 @@ main > h2 {
|
||||
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
|
||||
|
||||
.actions-special > .btn {text-align: left;}
|
||||
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
|
||||
.page-actions { display: contents; }
|
||||
/* Header actions carry an icon and a label: the mobile dropdown shows the
|
||||
label, the inline desktop bar (see responsive block) the icon. */
|
||||
.btn-icon { display: none; }
|
||||
@@ -433,17 +458,19 @@ main > h2 {
|
||||
}
|
||||
.suggest-dropdown.is-open { display: block; }
|
||||
.suggest-row { cursor: pointer; }
|
||||
.suggest-row > td { padding: 0.4rem 0.6rem; }
|
||||
.suggest-row > td { padding: 0; }
|
||||
/* The link fills the row, so every part of it is the click target. */
|
||||
.suggest-link { display: block; padding: 0.4rem 0.6rem; color: inherit; text-decoration: none; }
|
||||
.suggest-name, .suggest-path { display: block; }
|
||||
.suggest-name { color: var(--text); }
|
||||
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
|
||||
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); }
|
||||
.suggest-footer .suggest-link { color: var(--link); font-size: var(--font-sm); }
|
||||
|
||||
/* === Editor toolbar ===
|
||||
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;
|
||||
@@ -733,17 +760,33 @@ aside.sidebar:empty { display: none; }
|
||||
padding: 0.25rem var(--space-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
|
||||
.tree-row.is-selected {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(var(--space-2) - 3px);
|
||||
}
|
||||
.tree-row:hover { background: var(--bg-panel-hover); }
|
||||
.tree-row.is-active > .tree-name { color: var(--secondary); }
|
||||
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
|
||||
.tree-row.is-disabled:hover { background: none; }
|
||||
.tree-chevron { text-align: center; flex-shrink: 0; }
|
||||
.tree-chevron { width: 1.25rem; color: var(--secondary); }
|
||||
.tree-chevron.is-leaf { visibility: hidden; }
|
||||
/* Expandable folders are <details>; the chevron follows its open state. */
|
||||
.tree-node > summary { list-style: none; }
|
||||
.tree-node > summary::-webkit-details-marker { display: none; }
|
||||
.tree-node > summary > .tree-chevron::before { content: "\25b8"; }
|
||||
.tree-node[open] > summary > .tree-chevron::before { content: "\25be"; }
|
||||
/* Picker rows select through a visually hidden radio in the name label. */
|
||||
.tree-name input[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
}
|
||||
.tree-row:has(input:checked) {
|
||||
background: var(--bg-panel-hover);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(var(--space-2) - 3px);
|
||||
}
|
||||
.tree-row:has(input:focus-visible) { outline: 1px solid var(--secondary); }
|
||||
label.tree-name { flex: 1; cursor: pointer; position: relative; }
|
||||
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-children {
|
||||
margin-left: var(--space-2);
|
||||
@@ -895,6 +938,301 @@ aside.sidebar:empty { display: none; }
|
||||
}
|
||||
.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; }
|
||||
|
||||
+31
-247
@@ -1,43 +1,29 @@
|
||||
// openTreePicker shows a modal to pick a wiki path. The tree is server-rendered
|
||||
// (?tree&pick=…, see tree.go and tree.html): each selectable row carries a
|
||||
// radio named "pick", folders expand natively and lazy-load via htmx. This
|
||||
// file only hosts the tree in a modal and reports the checked row.
|
||||
//
|
||||
// opts: title, confirmLabel, mode ('folder' | 'file' | 'any'), initialPath
|
||||
// (expanded on open), preselect (checked on open), hideFiles, allowRoot
|
||||
// (default true: offer "/" in folder modes), onSelect(path, kind).
|
||||
(function () {
|
||||
function joinPath(parent, name) {
|
||||
if (parent === '/' || parent === '') return '/' + name;
|
||||
return parent.replace(/\/+$/, '') + '/' + name;
|
||||
}
|
||||
|
||||
function encodePath(p) {
|
||||
if (p === '/' || p === '') return '/';
|
||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
|
||||
}
|
||||
|
||||
function fetchFolder(path) {
|
||||
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function openTreePicker(opts) {
|
||||
opts = opts || {};
|
||||
var mode = opts.mode || 'folder';
|
||||
var initialPath = opts.initialPath || '/';
|
||||
var allowRoot = opts.allowRoot !== false;
|
||||
var hideFiles = !!opts.hideFiles;
|
||||
var preselect = opts.preselect || null;
|
||||
|
||||
var container = document.createElement('div');
|
||||
|
||||
var treeEl = document.createElement('div');
|
||||
treeEl.className = 'tree-picker panel';
|
||||
|
||||
treeEl.textContent = '…';
|
||||
var selectedPathEl = document.createElement('div');
|
||||
selectedPathEl.className = 'tree-selected-path muted';
|
||||
selectedPathEl.textContent = '\u00a0';
|
||||
|
||||
selectedPathEl.textContent = ' ';
|
||||
container.appendChild(treeEl);
|
||||
container.appendChild(selectedPathEl);
|
||||
|
||||
var selected = null; // { path, kind, rowEl }
|
||||
function checked() {
|
||||
return treeEl.querySelector('input[name="pick"]:checked');
|
||||
}
|
||||
|
||||
var handle = openModal({
|
||||
title: opts.title || 'Pick',
|
||||
@@ -46,236 +32,34 @@
|
||||
label: opts.confirmLabel || 'SELECT',
|
||||
initiallyDisabled: true,
|
||||
onConfirm: function () {
|
||||
if (!selected) return;
|
||||
var pick = checked();
|
||||
if (!pick) return;
|
||||
handle.close();
|
||||
if (opts.onSelect) opts.onSelect(selected.path, selected.kind);
|
||||
if (opts.onSelect) opts.onSelect(pick.value, pick.dataset.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setSelected(path, kind, rowEl) {
|
||||
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
|
||||
selected = { path: path, kind: kind, rowEl: rowEl };
|
||||
rowEl.classList.add('is-selected');
|
||||
selectedPathEl.textContent = path;
|
||||
handle.setConfirmDisabled(false);
|
||||
function sync() {
|
||||
var pick = checked();
|
||||
selectedPathEl.textContent = pick ? pick.value : ' ';
|
||||
handle.setConfirmDisabled(!pick);
|
||||
}
|
||||
treeEl.addEventListener('change', sync);
|
||||
|
||||
function isSelectable(kind) {
|
||||
if (mode === 'any') return true;
|
||||
if (mode === 'folder') return kind === 'folder';
|
||||
if (mode === 'file') return kind === 'file';
|
||||
return false;
|
||||
}
|
||||
|
||||
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
|
||||
// for files and idempotent for folders (resolves with the already-
|
||||
// loaded children on repeat calls).
|
||||
function buildRow(parentPath, name, kind) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'tree-row';
|
||||
if (!isSelectable(kind)) row.classList.add('is-disabled');
|
||||
|
||||
var chevron = document.createElement('span');
|
||||
chevron.className = 'tree-chevron';
|
||||
if (kind === 'folder') {
|
||||
chevron.textContent = '\u25b8'; // ▸
|
||||
} else {
|
||||
chevron.classList.add('is-leaf');
|
||||
var url = '/?tree&pick=' + mode +
|
||||
(opts.hideFiles ? '' : '&files=1') +
|
||||
(opts.allowRoot !== false ? '&root=1' : '') +
|
||||
'&expandTo=' + encodeURIComponent(opts.initialPath || '/') +
|
||||
(opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : '');
|
||||
htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () {
|
||||
sync();
|
||||
var pick = checked();
|
||||
if (pick) {
|
||||
try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
}
|
||||
row.appendChild(chevron);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'tree-name';
|
||||
label.textContent = name;
|
||||
row.appendChild(label);
|
||||
|
||||
var fullPath = joinPath(parentPath, name);
|
||||
var childrenEl = null;
|
||||
var loadPromise = null;
|
||||
var isOpen = false;
|
||||
|
||||
function expand() {
|
||||
if (kind !== 'folder') return Promise.resolve(null);
|
||||
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
|
||||
if (!childrenEl) {
|
||||
childrenEl = document.createElement('div');
|
||||
childrenEl.className = 'tree-children';
|
||||
}
|
||||
row.parentNode.insertBefore(childrenEl, row.nextSibling);
|
||||
chevron.textContent = '\u25be'; // ▾
|
||||
isOpen = true;
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadInto(fullPath, childrenEl);
|
||||
}
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
if (kind !== 'folder' || !isOpen) return;
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl);
|
||||
}
|
||||
chevron.textContent = '\u25b8';
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
chevron.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (isOpen) collapse(); else expand();
|
||||
});
|
||||
|
||||
row.addEventListener('click', function () {
|
||||
if (isSelectable(kind)) {
|
||||
setSelected(fullPath, kind, row);
|
||||
} else if (kind === 'folder') {
|
||||
if (isOpen) collapse(); else expand();
|
||||
}
|
||||
});
|
||||
|
||||
if (kind === 'folder') {
|
||||
row.addEventListener('dblclick', function (e) {
|
||||
e.preventDefault();
|
||||
if (isOpen) collapse(); else expand();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rowEl: row,
|
||||
name: name,
|
||||
kind: kind,
|
||||
childrenEl: function () { return childrenEl; },
|
||||
expand: expand
|
||||
};
|
||||
}
|
||||
|
||||
// loadInto fetches folderPath and populates `target` with rows. Returns
|
||||
// an array of the row objects on success, or [] on failure.
|
||||
function loadInto(folderPath, target) {
|
||||
target.textContent = '';
|
||||
var loading = document.createElement('div');
|
||||
loading.className = 'tree-row is-disabled';
|
||||
loading.textContent = '\u2026';
|
||||
target.appendChild(loading);
|
||||
|
||||
return fetchFolder(folderPath).then(function (resp) {
|
||||
target.textContent = '';
|
||||
var rows = [];
|
||||
(resp.entries || []).forEach(function (e) {
|
||||
if (hideFiles && e.kind !== 'folder') return;
|
||||
var r = buildRow(resp.path, e.name, e.kind);
|
||||
target.appendChild(r.rowEl);
|
||||
rows.push(r);
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'tree-row is-disabled';
|
||||
empty.textContent = '(empty)';
|
||||
target.appendChild(empty);
|
||||
}
|
||||
return rows;
|
||||
}).catch(function () {
|
||||
target.textContent = '';
|
||||
var err = document.createElement('div');
|
||||
err.className = 'tree-row';
|
||||
err.textContent = '(failed — tap to retry)';
|
||||
err.addEventListener('click', function () {
|
||||
loadInto(folderPath, target);
|
||||
});
|
||||
target.appendChild(err);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// Root selection row — visible when allowRoot and mode accepts folders.
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var rootRow = document.createElement('div');
|
||||
rootRow.className = 'tree-row';
|
||||
var rootChev = document.createElement('span');
|
||||
rootChev.className = 'tree-chevron is-leaf';
|
||||
rootRow.appendChild(rootChev);
|
||||
var rootLabel = document.createElement('span');
|
||||
rootLabel.className = 'tree-name';
|
||||
rootLabel.textContent = '/';
|
||||
rootRow.appendChild(rootLabel);
|
||||
rootRow.addEventListener('click', function () {
|
||||
setSelected('/', 'folder', rootRow);
|
||||
});
|
||||
treeEl.appendChild(rootRow);
|
||||
}
|
||||
|
||||
var rootChildren = document.createElement('div');
|
||||
treeEl.appendChild(rootChildren);
|
||||
|
||||
// Expand the ancestor chain of initialPath so the user lands in
|
||||
// context. For root, just load root children.
|
||||
var segs = (initialPath || '/').split('/').filter(Boolean);
|
||||
var preselectSegs = preselect
|
||||
? preselect.split('/').filter(Boolean)
|
||||
: null;
|
||||
|
||||
loadInto('/', rootChildren).then(function (rows) {
|
||||
return expandChain(rows, segs, '/');
|
||||
}).then(function () {
|
||||
if (preselectSegs === null) return;
|
||||
if (preselectSegs.length === 0) {
|
||||
// Preselect root itself (if allowed).
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var root = treeEl.querySelector('.tree-row');
|
||||
if (root) setSelected('/', 'folder', root);
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectByPath(preselectSegs);
|
||||
});
|
||||
|
||||
// expandChain walks `segments`, looking up each by name in the current
|
||||
// row list, expanding it, and recursing into its children.
|
||||
function expandChain(rows, segments, basePath) {
|
||||
if (segments.length === 0) return Promise.resolve();
|
||||
var seg = segments[0];
|
||||
var match = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].kind === 'folder' && rows[i].name === seg) {
|
||||
match = rows[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) return Promise.resolve();
|
||||
return match.expand().then(function (childRows) {
|
||||
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
|
||||
});
|
||||
}
|
||||
|
||||
// selectByPath walks the visible tree rows to locate the row matching
|
||||
// `segments` and marks it selected. Assumes its ancestors are already
|
||||
// expanded (expandChain ran first).
|
||||
function selectByPath(segments) {
|
||||
var container = rootChildren;
|
||||
var path = '/';
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i];
|
||||
var kids = container.children;
|
||||
var found = null;
|
||||
for (var j = 0; j < kids.length; j++) {
|
||||
var row = kids[j];
|
||||
if (!row.classList || !row.classList.contains('tree-row')) continue;
|
||||
var nm = row.querySelector('.tree-name');
|
||||
if (nm && nm.textContent === seg) { found = row; break; }
|
||||
}
|
||||
if (!found) return;
|
||||
path = joinPath(path, seg);
|
||||
if (i === segments.length - 1) {
|
||||
if (isSelectable('folder')) setSelected(path, 'folder', found);
|
||||
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
var next = found.nextSibling;
|
||||
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
|
||||
container = next;
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
;(function () {
|
||||
// This script owns the .tree-scroll child of the aside, not the aside itself.
|
||||
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
|
||||
if (!container) return
|
||||
|
||||
function joinPath(parent, name) {
|
||||
if (parent === "/" || parent === "") return "/" + name
|
||||
return parent.replace(/\/+$/, "") + "/" + name
|
||||
}
|
||||
|
||||
function encodeSegments(p) {
|
||||
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
function folderHref(p) {
|
||||
if (p === "/" || p === "") return "/"
|
||||
return "/" + encodeSegments(p) + "/"
|
||||
}
|
||||
|
||||
function fetchFolder(path, expandTo) {
|
||||
var url = folderHref(path) + "?tree=1&foldersOnly=1"
|
||||
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
|
||||
return fetch(url, { credentials: "same-origin" }).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status)
|
||||
return r.json()
|
||||
})
|
||||
}
|
||||
|
||||
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
|
||||
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
|
||||
// thus fullPath) are decoded, so we must decode here too or paths containing
|
||||
// spaces / non-ASCII never match a row and the chain never expands.
|
||||
var activePath = (function () {
|
||||
var p = window.location.pathname.replace(/\/+$/, "")
|
||||
if (p === "") return "/"
|
||||
try {
|
||||
return decodeURIComponent(p)
|
||||
} catch (e) {
|
||||
return p
|
||||
}
|
||||
})()
|
||||
var activeRow = null
|
||||
var live = false // set once the fetched tree is on screen
|
||||
|
||||
function disabledRow(text) {
|
||||
var row = document.createElement("div")
|
||||
row.className = "tree-row is-disabled"
|
||||
row.textContent = text
|
||||
return row
|
||||
}
|
||||
|
||||
// renderInto fills containerEl with rows for `entries` (children of
|
||||
// parentPath). Folders carrying a `children` array (the pre-expanded
|
||||
// ancestor chain from ?expandTo) are opened immediately and recurse.
|
||||
function renderInto(containerEl, parentPath, entries) {
|
||||
entries.forEach(function (entry) {
|
||||
var built = buildRow(parentPath, entry)
|
||||
containerEl.appendChild(built.row)
|
||||
if (built.preExpand) built.open()
|
||||
})
|
||||
}
|
||||
|
||||
function buildRow(parentPath, entry) {
|
||||
var fullPath = joinPath(parentPath, entry.name)
|
||||
var row = document.createElement("div")
|
||||
row.className = "tree-row"
|
||||
|
||||
var chevron = document.createElement("span")
|
||||
chevron.className = "tree-chevron"
|
||||
chevron.textContent = "▸"
|
||||
row.appendChild(chevron)
|
||||
|
||||
var link = document.createElement("a")
|
||||
link.className = "tree-name tree-folder"
|
||||
link.textContent = entry.name
|
||||
link.href = folderHref(fullPath)
|
||||
row.appendChild(link)
|
||||
|
||||
if (fullPath === activePath) {
|
||||
row.classList.add("is-active")
|
||||
activeRow = row
|
||||
}
|
||||
|
||||
var preChildren = entry.children ? entry.children : null
|
||||
var childrenEl = null
|
||||
var loaded = false
|
||||
var isOpen = false
|
||||
var isLeaf = !entry.hasChildren
|
||||
if (isLeaf) chevron.classList.add("is-leaf")
|
||||
|
||||
function ensureChildrenEl() {
|
||||
if (!childrenEl) {
|
||||
childrenEl = document.createElement("div")
|
||||
childrenEl.className = "tree-children"
|
||||
}
|
||||
return childrenEl
|
||||
}
|
||||
|
||||
function markLeaf() {
|
||||
isLeaf = true
|
||||
isOpen = false
|
||||
chevron.textContent = "▸"
|
||||
chevron.classList.add("is-leaf")
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl)
|
||||
}
|
||||
}
|
||||
|
||||
function loadChildren() {
|
||||
var el = ensureChildrenEl()
|
||||
el.textContent = ""
|
||||
el.appendChild(disabledRow("…"))
|
||||
fetchFolder(fullPath)
|
||||
.then(function (resp) {
|
||||
el.textContent = ""
|
||||
loaded = true
|
||||
if (!resp.entries || resp.entries.length === 0) {
|
||||
markLeaf()
|
||||
return
|
||||
}
|
||||
renderInto(el, fullPath, resp.entries)
|
||||
})
|
||||
.catch(function () {
|
||||
el.textContent = ""
|
||||
var err = document.createElement("div")
|
||||
err.className = "tree-row"
|
||||
err.textContent = "(failed — tap to retry)"
|
||||
err.addEventListener("click", function (e) {
|
||||
e.stopPropagation()
|
||||
loadChildren()
|
||||
})
|
||||
el.appendChild(err)
|
||||
})
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (isOpen || isLeaf) return
|
||||
var el = ensureChildrenEl()
|
||||
row.parentNode.insertBefore(el, row.nextSibling)
|
||||
chevron.textContent = "▾" // ▾
|
||||
isOpen = true
|
||||
if (!loaded) {
|
||||
if (preChildren) {
|
||||
renderInto(el, fullPath, preChildren)
|
||||
loaded = true
|
||||
} else {
|
||||
loadChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen) return
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl)
|
||||
}
|
||||
chevron.textContent = "▸"
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
// Clicking the name link navigates (folder) or opens the file; clicking
|
||||
// anywhere else on the row toggles expansion for folders.
|
||||
row.addEventListener("click", function (e) {
|
||||
if (e.target.closest("a.tree-name")) return
|
||||
if (isOpen) close()
|
||||
else open()
|
||||
})
|
||||
|
||||
return { row: row, open: open, preExpand: !!preChildren }
|
||||
}
|
||||
|
||||
function render() {
|
||||
// If the layout pre-painted a snapshot of the previous page's tree, keep it
|
||||
// on screen and build the live tree off-DOM, swapping it in once ready —
|
||||
// no "…" placeholder, no scroll jump.
|
||||
var listing = document.createElement("div")
|
||||
var hasSnapshot = container.firstChild !== null
|
||||
if (!hasSnapshot) {
|
||||
container.appendChild(listing)
|
||||
listing.appendChild(disabledRow("…"))
|
||||
}
|
||||
|
||||
fetchFolder("/", activePath === "/" ? "" : activePath)
|
||||
.then(function (resp) {
|
||||
listing.textContent = ""
|
||||
renderInto(listing, "/", resp.entries)
|
||||
if (hasSnapshot) {
|
||||
var top = container.scrollTop
|
||||
container.replaceChildren(listing)
|
||||
container.scrollTop = top
|
||||
}
|
||||
live = true
|
||||
if (activeRow) {
|
||||
try {
|
||||
activeRow.scrollIntoView({ block: "nearest" })
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
live = false
|
||||
container.replaceChildren(listing)
|
||||
listing.textContent = ""
|
||||
var err = document.createElement("div")
|
||||
err.className = "tree-row"
|
||||
err.textContent = "(failed — tap to retry)"
|
||||
err.addEventListener("click", function () {
|
||||
container.textContent = ""
|
||||
render()
|
||||
})
|
||||
listing.appendChild(err)
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshot the tree (markup + scroll) on the way out so the next page's
|
||||
// layout can paint it immediately. Skipped until the live tree has rendered,
|
||||
// so a placeholder or error row never becomes the snapshot. The active
|
||||
// highlight is left out; the next page marks its own row.
|
||||
window.addEventListener("pagehide", function () {
|
||||
if (!live) return
|
||||
if (activeRow) activeRow.classList.remove("is-active")
|
||||
var html = container.innerHTML
|
||||
if (activeRow) activeRow.classList.add("is-active") // bfcache may restore this page
|
||||
try {
|
||||
sessionStorage.setItem("tree-snapshot", JSON.stringify({ html: html, top: container.scrollTop }))
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
function setupMenuItem() {
|
||||
var specials = document.querySelector(".actions-special")
|
||||
if (!specials) return
|
||||
|
||||
var item = document.createElement("button")
|
||||
item.type = "button"
|
||||
item.className = "btn btn-block"
|
||||
item.title = "Folders"
|
||||
item.textContent = "FOLDERS"
|
||||
item.addEventListener("click", function () {
|
||||
if (typeof openOverlay === "function") openOverlay(container)
|
||||
})
|
||||
specials.appendChild(item)
|
||||
}
|
||||
|
||||
render()
|
||||
setupMenuItem()
|
||||
})()
|
||||
@@ -0,0 +1,18 @@
|
||||
{{/* Folder tree rows (tree.go), shared by the sidebar and the tree picker.
|
||||
Expandable folders are native <details>: open/close needs no script, and a
|
||||
folder's children load via htmx the first time it opens, unless the server
|
||||
pre-expanded them. A label inside <summary> selects its radio without
|
||||
toggling the folder; the rest of the row toggles. */}}
|
||||
|
||||
{{define "rows"}}{{range .}}{{if .Expandable}}<details class="tree-node"{{if .Children}} open{{end}}>
|
||||
<summary class="{{template "row-class" .}}"><span class="tree-chevron" aria-hidden="true"></span>{{template "label" .}}</summary>
|
||||
<div class="tree-children"{{if not .Children}} hx-get="{{.ChildrenURL}}" hx-trigger="toggle once from:closest details"{{end}}>{{template "rows" .Children}}</div>
|
||||
</details>
|
||||
{{else}}<div class="{{template "row-class" .}}"><span class="tree-chevron is-leaf" aria-hidden="true"></span>{{template "label" .}}</div>
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
{{define "row-class"}}tree-row{{if .Active}} is-active{{end}}{{if and (not .Link) (not .Selectable)}} is-disabled{{end}}{{end}}
|
||||
|
||||
{{define "label"}}{{if .Link}}<a class="tree-name tree-{{.Kind}}" href="{{.URL}}">{{.Name}}</a>
|
||||
{{- else if .Selectable}}<label class="tree-name"><input type="radio" name="pick" value="{{.Path}}" data-kind="{{.Kind}}"{{if .Checked}} checked{{end}} />{{.Name}}</label>
|
||||
{{- else}}<span class="tree-name">{{.Name}}</span>{{end}}{{end}}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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\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, "<b>G</b>") || !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)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var dialogTmpl = template.Must(template.New("dialogs").ParseFS(assets, "assets/page/dialogs.html"))
|
||||
|
||||
// isHX reports whether r was issued by htmx.
|
||||
func isHX(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
// layoutTemplate names the template that renders a layout page for r: the
|
||||
// whole document, or for boosted navigation and htmx history restores only
|
||||
// the "partial" that hx.js swaps into the open page. The editor is never
|
||||
// boosted.
|
||||
func layoutTemplate(w http.ResponseWriter, r *http.Request, editMode bool) string {
|
||||
w.Header().Set("Vary", "HX-Boosted, HX-History-Restore-Request")
|
||||
if !editMode && (r.Header.Get("HX-Boosted") == "true" || r.Header.Get("HX-History-Restore-Request") == "true") {
|
||||
return "partial"
|
||||
}
|
||||
return "layout"
|
||||
}
|
||||
|
||||
// redirectAfter sends the client to target once a mutation succeeded. htmx
|
||||
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
|
||||
// Back skips the pre-mutation page); plain form posts get a 303.
|
||||
func redirectAfter(w http.ResponseWriter, r *http.Request, target string) {
|
||||
if isHX(r) {
|
||||
w.Header().Set("HX-Redirect", target)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// pageURL returns the escaped, slash-terminated browser URL of the page at
|
||||
// the wiki path p ("/" or "/a/b", trailing slash optional).
|
||||
func pageURL(p string) string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
return fileURL(p) + "/"
|
||||
}
|
||||
|
||||
// renderDialog writes the named dialog from dialogs.html.
|
||||
func renderDialog(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := dialogTmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("dialog template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serveDialog handles GET ?dialog=<name> for the page at urlPath.
|
||||
func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||
page := normalizeMovePath(urlPath)
|
||||
name := r.URL.Query().Get("dialog")
|
||||
if page == "/" && (name == "delete" || name == "move") {
|
||||
http.Error(w, "not available for the wiki root", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch name {
|
||||
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":
|
||||
// parent comes from the folder picker; default to the current one.
|
||||
parent := r.URL.Query().Get("parent")
|
||||
if parent == "" {
|
||||
parent = path.Dir(page)
|
||||
}
|
||||
renderDialog(w, "move", struct{ Parent, Name string }{parent, path.Base(page)})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
Generated
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "datascape-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "datascape-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "datascape-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = process.env.E2E_PORT || "8765";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "tests",
|
||||
// Tests share one server and wiki but each works in its own sandbox folder,
|
||||
// so they can run in parallel.
|
||||
workers: 4,
|
||||
fullyParallel: true,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
// The system Chrome; no browser download needed.
|
||||
{ name: "chrome", use: { channel: "chrome" } },
|
||||
// Playwright's Firefox build: npx playwright install firefox
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
],
|
||||
webServer: {
|
||||
command: "node server.mjs",
|
||||
url: `http://127.0.0.1:${port}/`,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
stdout: "pipe",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Builds datascape, seeds a throwaway wiki and runs the server in the
|
||||
// foreground. Playwright's webServer starts this and kills it after the run.
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const tmp = join(here, ".tmp");
|
||||
const wiki = join(tmp, "wiki");
|
||||
const bin = join(tmp, process.platform === "win32" ? "datascape.exe" : "datascape");
|
||||
const port = process.env.E2E_PORT || "8765";
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
mkdirSync(wiki, { recursive: true });
|
||||
|
||||
execFileSync("go", ["build", "-o", bin, "."], { cwd: join(here, ".."), stdio: "inherit" });
|
||||
|
||||
// Tests create their own pages (see sandbox in tests/pages.spec.js); the seed
|
||||
// is only the home page.
|
||||
writeFileSync(join(wiki, "index.md"), "# Home\n\nWelcome.\n");
|
||||
|
||||
const srv = spawn(bin, ["-dir", wiki, "-cache", join(tmp, "cache"), "-addr", "127.0.0.1:" + port, "-calendars", ""], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
srv.on("exit", (code) => process.exit(code ?? 0));
|
||||
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => srv.kill());
|
||||
@@ -0,0 +1,155 @@
|
||||
// Boosted navigation: outside the editor, page links swap the next page into
|
||||
// the open document instead of loading a new one. A marker on window survives
|
||||
// a boosted navigation and disappears with a full page load.
|
||||
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) => {
|
||||
// Chrome aborts the cross-document view transition (style.css) when the
|
||||
// next document can't take part, e.g. a plain-text file, and reports it
|
||||
// as an unhandled rejection that no page script can catch.
|
||||
if (err.message.includes("ViewTransition opt-in disabled")) return;
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
|
||||
function randomWord(n) {
|
||||
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
|
||||
}
|
||||
|
||||
function sandbox(pages) {
|
||||
const root = "t" + randomWord(7);
|
||||
for (const [rel, body] of Object.entries(pages)) {
|
||||
const dir = join(wiki, root, rel);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
if (body !== null) writeFileSync(join(dir, "index.md"), body);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const sidebar = (page) => page.locator("aside.tree-sidebar");
|
||||
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
|
||||
const mark = (page) => page.evaluate(() => (window.__marker = 1));
|
||||
const marked = (page) => page.evaluate(() => window.__marker === 1);
|
||||
const long = (title) => `# ${title}\n\n` + Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\ntext\n`).join("\n");
|
||||
|
||||
test("sidebar navigation swaps the page in without a reload", async ({ page }) => {
|
||||
const root = sandbox({
|
||||
"": "# Parent\n",
|
||||
Child: "# Child\n\n## First\n\none\n\n## Second\n\n- [ ] boosted task\n",
|
||||
});
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
await link(sidebar(page), "Child").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
await expect(page).toHaveTitle("Child");
|
||||
expect(await marked(page)).toBe(true);
|
||||
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText("Child");
|
||||
// Page scripts ran for the new content: TOC and section edit links.
|
||||
await expect(page.locator("nav.toc")).toContainText("Second");
|
||||
await expect(page.locator("main h2", { hasText: "First" }).getByRole("link", { name: "[edit]" })).toBeVisible();
|
||||
// htmx attributes in the new content work: the task toggles.
|
||||
await page.locator("main input.task-checkbox").check();
|
||||
await expect.poll(() => readFileSync(join(wiki, root, "Child", "index.md"), "utf8")).toContain("- [x] boosted task");
|
||||
});
|
||||
|
||||
test("Back and Forward restore pages and scroll without a reload", async ({ page }) => {
|
||||
const root = sandbox({ "": long("Parent"), Child: long("Child") });
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
const center = page.locator(".center");
|
||||
await center.evaluate((el) => (el.scrollTop = 600));
|
||||
await link(sidebar(page), "Child").click();
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
// A new page starts at the top.
|
||||
expect(await center.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(page).toHaveTitle("Parent");
|
||||
await expect.poll(() => center.evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
|
||||
await page.goForward();
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("Back to the first, fully loaded page works", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
||||
await page.goto(`/${root}/Child/`);
|
||||
await mark(page);
|
||||
await link(sidebar(page), root).click();
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("files and the editor load as full pages", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n" });
|
||||
writeFileSync(join(wiki, root, "note.txt"), "plain file");
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
await page.locator("main").getByRole("link", { name: "note.txt" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/note.txt$`));
|
||||
await expect(page.locator("body")).toContainText("plain file");
|
||||
expect(await marked(page)).toBe(false);
|
||||
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
await page.locator("header").getByTitle("Edit page (E)").click();
|
||||
await expect(page).toHaveURL(/\?edit/);
|
||||
await expect(page.locator(".cm-content")).toBeVisible();
|
||||
expect(await marked(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("header actions follow the page", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n" });
|
||||
await page.goto("/");
|
||||
await mark(page);
|
||||
const move = page.locator("header").getByTitle("Move page (M)");
|
||||
await expect(move).toHaveCount(0);
|
||||
await link(sidebar(page), root).click();
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(move).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(page.locator("main h1")).toContainText("Home");
|
||||
await expect(move).toHaveCount(0);
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("search results and the search box follow boosted navigation", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
||||
expect((await page.request.post("/_reindex")).status()).toBe(204);
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
const input = page.locator("header .search-input");
|
||||
await input.fill(root);
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/\?q=${root}$`));
|
||||
await expect(page).toHaveTitle(`Search: ${root}`);
|
||||
await expect(page.locator("main .search-card").first()).toBeVisible();
|
||||
await link(sidebar(page), root).click();
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(input).toHaveValue("");
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("a mutation swaps in its result without a reload", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
||||
await page.goto(`/${root}/Gone/`);
|
||||
await mark(page);
|
||||
await page.locator("header").getByTitle("Delete page").click();
|
||||
await page.locator(".modal").getByRole("button", { name: "DELETE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(page.locator(".modal")).toHaveCount(0);
|
||||
await expect(link(sidebar(page), "Gone")).toHaveCount(0);
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
// End-to-end coverage of the page UI. Tests drive the app through visible
|
||||
// labels, not implementation details, so they survive changes to the
|
||||
// client-side mechanics.
|
||||
//
|
||||
// Every test builds the pages it needs under its own fresh top-level folder
|
||||
// (sandbox), so tests don't depend on each other's leftovers and can run alone
|
||||
// or in any order. Names are matched exactly: other tests' folders share the
|
||||
// sidebar and the search index.
|
||||
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");
|
||||
|
||||
// Fail any test that logs a JS error.
|
||||
test.beforeEach(({ page }) => {
|
||||
page.on("pageerror", (err) => {
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
|
||||
// randomWord returns n random lowercase letters.
|
||||
function randomWord(n) {
|
||||
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
|
||||
}
|
||||
|
||||
// sandbox creates a fresh top-level folder holding pages, keyed by path
|
||||
// relative to it ("" is the folder's own page); a null body makes an empty
|
||||
// folder. Returns the folder name.
|
||||
function sandbox(pages) {
|
||||
const root = "t" + randomWord(7);
|
||||
for (const [rel, body] of Object.entries(pages)) {
|
||||
const dir = join(wiki, root, rel);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
if (body !== null) writeFileSync(join(dir, "index.md"), body);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const readPage = (root, rel = "") => readFileSync(join(wiki, root, rel, "index.md"), "utf8");
|
||||
const exactly = (name) => new RegExp(`^${name}$`);
|
||||
const sidebar = (page) => page.locator("aside.tree-sidebar");
|
||||
const modal = (page) => page.locator(".modal");
|
||||
const treeRow = (scope, name) => scope.locator(".tree-row", { hasText: exactly(name) });
|
||||
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
|
||||
|
||||
// Page actions sit in a dropdown on narrow screens and inline (icon-only) on
|
||||
// wide ones; address them by title and open the dropdown only when present.
|
||||
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();
|
||||
}
|
||||
|
||||
// movePage runs the move flow from the current page: keep the preselected
|
||||
// parent, then enter name.
|
||||
async function movePage(page, name) {
|
||||
await pageAction(page, "Move page (M)");
|
||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||
await modal(page).getByPlaceholder("Page name").fill(name);
|
||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||
}
|
||||
|
||||
test("renders a page and navigates via the sidebar", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
||||
await page.goto("/");
|
||||
await expect(page.locator("main h1")).toContainText("Home");
|
||||
await link(sidebar(page), root).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
// The sidebar expands the active folder's children.
|
||||
await expect(link(sidebar(page), "Child")).toBeVisible();
|
||||
});
|
||||
|
||||
test("task checkbox toggle persists", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
|
||||
await page.goto(`/${root}/`);
|
||||
const box = page.locator("main input.task-checkbox").first();
|
||||
await expect(box).not.toBeChecked();
|
||||
await box.check();
|
||||
await expect(box).toBeEnabled();
|
||||
await expect.poll(() => readPage(root)).toContain("- [x] first task");
|
||||
await page.reload();
|
||||
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
|
||||
});
|
||||
|
||||
test("unchecking a task persists", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
|
||||
await page.goto(`/${root}/`);
|
||||
const box = page.locator("main input.task-checkbox").nth(1);
|
||||
await expect(box).toBeChecked();
|
||||
await box.uncheck();
|
||||
await expect(box).toBeEnabled();
|
||||
await expect.poll(() => readPage(root)).toContain("- [ ] second task");
|
||||
});
|
||||
|
||||
test("a failed task save reverts the checkbox", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Tasks\n\n- [x] done task\n" });
|
||||
page.on("dialog", (d) => d.dismiss());
|
||||
await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" }));
|
||||
await page.goto(`/${root}/`);
|
||||
const box = page.locator("main input.task-checkbox").first();
|
||||
await box.click();
|
||||
await expect(box).toBeEnabled();
|
||||
await expect(box).toBeChecked();
|
||||
expect(readPage(root)).toContain("- [x] done task");
|
||||
});
|
||||
|
||||
test("view settings switch the listing to thumbnails", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Listing\n", Child: "# Child\n" });
|
||||
await page.goto(`/${root}/`);
|
||||
await expect(page.locator("main table.data-table")).toBeVisible();
|
||||
await page.getByTitle("View & sorting").click();
|
||||
await modal(page).getByText("Thumbnail").click();
|
||||
await modal(page).getByLabel("Sort by").selectOption("size");
|
||||
await modal(page).getByText("Desc").click();
|
||||
await modal(page).getByRole("button", { name: "SAVE" }).click();
|
||||
await expect(page.locator("main .thumb-grid")).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
// Reopening shows the saved state.
|
||||
await page.getByTitle("View & sorting").click();
|
||||
await expect(modal(page).getByLabel("Thumbnail")).toBeChecked();
|
||||
await expect(modal(page).getByLabel("Sort by")).toHaveValue("size");
|
||||
await expect(modal(page).getByLabel("Desc")).toBeChecked();
|
||||
});
|
||||
|
||||
test("editor saves a page", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Editable\n" });
|
||||
await page.goto(`/${root}/`);
|
||||
await pageAction(page, "Edit page (E)");
|
||||
await expect(page).toHaveURL(/\?edit/);
|
||||
await page.locator(".cm-content").click();
|
||||
await page.keyboard.press("Control+End");
|
||||
await page.keyboard.type("\nAdded by e2e.\n");
|
||||
await page.getByRole("button", { name: "SAVE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await expect(page.locator("main")).toContainText("Added by e2e.");
|
||||
expect(readPage(root)).toContain("Added by e2e.");
|
||||
// Save replaces the editor's history entry: Back leaves the page entirely.
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(/\?edit/);
|
||||
});
|
||||
|
||||
test("move renames a page", async ({ page }) => {
|
||||
const root = sandbox({ Beta: "# Beta\n" });
|
||||
await page.goto(`/${root}/Beta/`);
|
||||
await movePage(page, "Gamma");
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Gamma/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Beta");
|
||||
});
|
||||
|
||||
test("move onto an empty folder offers a merge", async ({ page }) => {
|
||||
const root = sandbox({ Alpha: "# Alpha\n", Empty: null });
|
||||
await page.goto(`/${root}/Alpha/`);
|
||||
await movePage(page, "Empty");
|
||||
await expect(modal(page)).toContainText("Merge folders?");
|
||||
await modal(page).getByRole("button", { name: "MERGE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Empty/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Alpha");
|
||||
});
|
||||
|
||||
test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => {
|
||||
const root = sandbox({ Mover: "# Mover\n", Taken: "# Taken\n" });
|
||||
await page.goto(`/${root}/Mover/`);
|
||||
// Dismiss the alert from the handler: an open alert blocks the click.
|
||||
const messages = [];
|
||||
page.on("dialog", (d) => {
|
||||
messages.push(d.message());
|
||||
d.dismiss();
|
||||
});
|
||||
await movePage(page, "Taken");
|
||||
await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]);
|
||||
await expect(modal(page)).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Mover/$`));
|
||||
});
|
||||
|
||||
test("delete removes a page and returns to the parent", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Notes: "# Notes\n" });
|
||||
await page.goto(`/${root}/Notes/`);
|
||||
await pageAction(page, "Delete page");
|
||||
await expect(modal(page)).toContainText(`Delete /${root}/Notes/`);
|
||||
await modal(page).getByRole("button", { name: "DELETE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await expect(link(sidebar(page), "Notes")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
||||
await page.goto(`/${root}/`);
|
||||
await page.goto(`/${root}/Gone/`);
|
||||
await pageAction(page, "Delete page");
|
||||
await modal(page).getByRole("button", { name: "DELETE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
});
|
||||
|
||||
test("a server dialog works from the first moment it is shown", async ({ page }) => {
|
||||
// htmx wires up swapped-in forms only when it settles. Submit in the very
|
||||
// next task after the dialog is inserted: if the form isn't wired yet, the
|
||||
// browser submits it natively (a GET) and the page is not deleted.
|
||||
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
||||
await page.goto(`/${root}/Gone/`);
|
||||
await page.evaluate(() => {
|
||||
document.addEventListener("htmx:afterSwap", (e) => {
|
||||
if (e.detail.target.id !== "modal-host") return;
|
||||
const submit = e.detail.target.querySelector('button[type="submit"]');
|
||||
setTimeout(() => submit.click(), 0);
|
||||
}, { once: true });
|
||||
});
|
||||
await pageAction(page, "Delete page");
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
});
|
||||
|
||||
test("server dialogs close with Escape and CANCEL", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Keep me\n" });
|
||||
await page.goto(`/${root}/`);
|
||||
await pageAction(page, "Delete page");
|
||||
await expect(modal(page)).toBeVisible();
|
||||
// CANCEL is focused, so Enter must not delete.
|
||||
await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(modal(page)).toHaveCount(0);
|
||||
await pageAction(page, "Delete page");
|
||||
await modal(page).getByRole("button", { name: "CANCEL" }).click();
|
||||
await expect(modal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
expect(readPage(root)).toContain("Keep me");
|
||||
});
|
||||
|
||||
test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Here\n" });
|
||||
const treeRequests = [];
|
||||
page.on("request", (r) => {
|
||||
if (r.url().includes("?tree")) treeRequests.push(r.url());
|
||||
});
|
||||
const res = await page.request.get(`/${root}/`);
|
||||
expect(await res.text()).toContain(`class="tree-name tree-folder" href="/${root}/"`);
|
||||
await page.goto(`/${root}/`);
|
||||
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText(root);
|
||||
expect(treeRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("sidebar folders expand lazily and collapse", async ({ page }) => {
|
||||
const root = sandbox({ "One/Deep": "# Deep\n", Two: "# Two\n" });
|
||||
await page.goto("/");
|
||||
const chevron = treeRow(sidebar(page), root).locator(".tree-chevron");
|
||||
const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content);
|
||||
await expect(link(sidebar(page), "One")).toHaveCount(0);
|
||||
expect(await glyph()).toBe('"▸"');
|
||||
await chevron.click();
|
||||
await expect(link(sidebar(page), "One")).toBeVisible();
|
||||
expect(await glyph()).toBe('"▾"');
|
||||
// One has a subfolder, Two has none: only One gets a chevron.
|
||||
await expect(sidebar(page).locator("summary.tree-row", { hasText: exactly("One") })).toHaveCount(1);
|
||||
await expect(treeRow(sidebar(page), "Two").locator(".tree-chevron")).toHaveClass(/is-leaf/);
|
||||
await chevron.click();
|
||||
await expect(link(sidebar(page), "One")).toBeHidden();
|
||||
});
|
||||
|
||||
test("tree picker selects inside a lazily expanded folder", async ({ page }) => {
|
||||
const root = sandbox({ Mover: "# Mover\n", "Tree/One/Deep": "# Deep\n" });
|
||||
await page.goto(`/${root}/Mover/`);
|
||||
await pageAction(page, "Move page (M)");
|
||||
// The picker opens with the current parent expanded; Tree is still closed.
|
||||
const picker = modal(page).locator(".tree-picker");
|
||||
await treeRow(picker, "Tree").locator(".tree-chevron").click();
|
||||
await treeRow(picker, "One").locator(".tree-chevron").click();
|
||||
await picker.getByText("Deep", { exact: true }).click();
|
||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/Tree/One/Deep`);
|
||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Tree/One/Deep/Mover/$`));
|
||||
});
|
||||
|
||||
test("tree picker can pick the root", async ({ page }) => {
|
||||
const root = sandbox({ Two: "# Two\n" });
|
||||
await page.goto(`/${root}/Two/`);
|
||||
await pageAction(page, "Move page (M)");
|
||||
// The current parent is preselected and its chain expanded.
|
||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}`);
|
||||
await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click();
|
||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||
await modal(page).getByPlaceholder("Page name").fill(`${root}-top`);
|
||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`127\\.0\\.0\\.1:\\d+/${root}-top/$`));
|
||||
});
|
||||
|
||||
test("editor embed picker selects a file", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Embeds\n" });
|
||||
writeFileSync(join(wiki, root, "photo.jpg"), "x");
|
||||
await page.goto(`/${root}/?edit`);
|
||||
await page.locator(".cm-content").click();
|
||||
await page.keyboard.press("Alt+Shift+G");
|
||||
const picker = modal(page).locator(".tree-picker");
|
||||
// Folder rows aren't selectable in file mode; the file is.
|
||||
await picker.getByText("photo.jpg").click();
|
||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/photo.jpg`);
|
||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||
await modal(page).getByRole("button", { name: "INSERT" }).click();
|
||||
await expect(page.locator(".cm-content")).toContainText("photo.jpg");
|
||||
});
|
||||
|
||||
test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Mobile\n" });
|
||||
await page.setViewportSize({ width: 400, height: 800 });
|
||||
await page.goto(`/${root}/`);
|
||||
await expect(sidebar(page)).toBeHidden();
|
||||
await page.getByRole("button", { name: "ACTIONS" }).click();
|
||||
await page.getByTitle("Folders").click();
|
||||
const overlay = page.locator(".overlay");
|
||||
await expect(link(overlay, root)).toBeVisible();
|
||||
await link(overlay, root).click();
|
||||
await expect(overlay).toHaveCount(0);
|
||||
});
|
||||
|
||||
// seedSearch creates pages whose names share a random word (so no other
|
||||
// test's pages match it) and reindexes. Returns the sandbox, the word and its
|
||||
// capitalized form used in the names.
|
||||
async function seedSearch(page) {
|
||||
const word = randomWord(8);
|
||||
const Word = word[0].toUpperCase() + word.slice(1);
|
||||
const pages = { "": "# Search\n" };
|
||||
for (const n of ["Zebra Crossing", "Zebra Finch", "1", "2", "3", "4", "5", "6"]) {
|
||||
pages[`${Word} ${n}`] = `# ${Word} ${n}\n`;
|
||||
}
|
||||
const root = sandbox(pages);
|
||||
expect((await page.request.post("/_reindex")).status()).toBe(204);
|
||||
return { root, word, Word };
|
||||
}
|
||||
const suggest = (page) => page.locator("header .suggest-dropdown");
|
||||
|
||||
test("header typeahead: highlight, keyboard pick", async ({ page }) => {
|
||||
const { root, word, Word } = await seedSearch(page);
|
||||
await page.goto("/");
|
||||
const input = page.locator("header .search-input");
|
||||
await input.fill(`${word} finch`);
|
||||
const first = suggest(page).locator(".suggest-row").first();
|
||||
await expect(first.locator(".suggest-name")).toHaveText(`${Word} Zebra Finch`);
|
||||
await expect(first.locator("strong")).toHaveText([Word, "Finch"]);
|
||||
await input.press("ArrowDown");
|
||||
await expect(suggest(page).locator(".suggest-row.is-active")).toContainText(`${Word} Zebra Finch`);
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Finch/$`));
|
||||
});
|
||||
|
||||
test("header typeahead: click, footer, escape, short query", async ({ page }) => {
|
||||
const { root, word, Word } = await seedSearch(page);
|
||||
await page.goto("/");
|
||||
const input = page.locator("header .search-input");
|
||||
await input.fill(word[0]);
|
||||
await page.waitForTimeout(300);
|
||||
await expect(suggest(page)).toBeHidden();
|
||||
await input.fill(word);
|
||||
const { total } = await (await page.request.get(`/_search?q=${word}`)).json();
|
||||
expect(total).toBeGreaterThan(5);
|
||||
await expect(suggest(page).locator(".suggest-footer")).toHaveText(`Show all ${total} matches`);
|
||||
await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5);
|
||||
await input.press("Escape");
|
||||
await expect(suggest(page)).toBeHidden();
|
||||
await input.fill(`${word} crossing`);
|
||||
await suggest(page).locator(".suggest-name", { hasText: `${Word} Zebra Crossing` }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Crossing/$`));
|
||||
await page.goto("/");
|
||||
await page.locator("header .search-input").fill(word);
|
||||
await suggest(page).locator(".suggest-footer").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/\\?q=${word}$`));
|
||||
});
|
||||
|
||||
test("editor link dialog fills the query from a suggestion", async ({ page }) => {
|
||||
const { root, word, Word } = await seedSearch(page);
|
||||
await page.goto(`/${root}/?edit`);
|
||||
await page.locator(".cm-content").click();
|
||||
await page.keyboard.press("Alt+Shift+P");
|
||||
const query = modal(page).getByPlaceholder("Search query");
|
||||
await query.fill(`${word} crossing`);
|
||||
await modal(page).locator(".suggest-row", { hasText: `${Word} Zebra Crossing` }).click();
|
||||
await expect(query).toHaveValue(`${Word} Zebra Crossing`);
|
||||
await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused();
|
||||
await modal(page).getByRole("button", { name: "INSERT" }).click();
|
||||
await expect(page.locator(".cm-content")).toContainText(`[[${Word} Zebra Crossing]]`);
|
||||
});
|
||||
|
||||
test("editor [[ autocomplete lists matching pages", async ({ page }) => {
|
||||
const { root, word, Word } = await seedSearch(page);
|
||||
await page.goto(`/${root}/?edit`);
|
||||
await page.locator(".cm-content").click();
|
||||
await page.keyboard.press("Control+End");
|
||||
await page.keyboard.type(`\n[[${word} finc`);
|
||||
await expect(page.locator(".cm-tooltip-autocomplete")).toContainText(`${Word} Zebra Finch`);
|
||||
});
|
||||
+61
-2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -301,6 +317,18 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
h.handlePost(w, r, urlPath, fsPath)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Has("dialog") {
|
||||
h.serveDialog(w, r, urlPath, fsPath)
|
||||
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 {
|
||||
@@ -402,12 +430,13 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
Content: rendered,
|
||||
Entries: entries,
|
||||
View: view,
|
||||
Sort: sortKey,
|
||||
Order: order,
|
||||
SpecialContent: specialContent,
|
||||
SidebarWidget: sidebarWidget,
|
||||
SuppressTOC: suppressTOC,
|
||||
}
|
||||
if !editMode {
|
||||
data.Tree = sidebarTree(h.root, urlPath)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
t := pageTmpl
|
||||
@@ -415,7 +444,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
t = editTmpl
|
||||
}
|
||||
data.renderTimer = renderTimer{requestStart(r)}
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -427,7 +456,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
if _, ok := query["move"]; ok {
|
||||
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
|
||||
// The destination comes either whole (?move=/dst, or the merge
|
||||
// dialog's move field) or as the move dialog's parent + name.
|
||||
// Body fields take precedence over the bare ?move query key.
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dst := r.FormValue("move")
|
||||
if name := strings.TrimSpace(r.FormValue("name")); dst == "" && name != "" {
|
||||
dst = strings.TrimRight(r.FormValue("parent"), "/") + "/" + name
|
||||
}
|
||||
h.handleMove(w, r, urlPath, fsPath, dst, r.FormValue("merge") != "")
|
||||
return
|
||||
}
|
||||
if query.Has("toggle") {
|
||||
@@ -442,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
|
||||
|
||||
@@ -14,8 +14,7 @@ import (
|
||||
// The one exception is a merge: when the source carries a page (index.md) and
|
||||
// the destination folder has none, the source's contents can fill the empty
|
||||
// container without clobbering anything. That merge only proceeds when merge
|
||||
// is true; otherwise the client is told a merge is available so it can ask the
|
||||
// user to confirm.
|
||||
// is true; otherwise the client is asked to confirm it.
|
||||
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
|
||||
oldPath := normalizeMovePath(srcURL)
|
||||
if oldPath == "/" {
|
||||
@@ -51,9 +50,12 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
||||
return
|
||||
}
|
||||
if !merge {
|
||||
// Ask the client to confirm the merge. The header lets it tell
|
||||
// this apart from an ordinary conflict.
|
||||
w.Header().Set("X-Merge-Available", "1")
|
||||
// Ask the user to confirm: htmx clients get the merge dialog as
|
||||
// the next step; others a conflict they can retry with &merge=1.
|
||||
if isHX(r) {
|
||||
renderDialog(w, "merge", struct{ Dest string }{newPath})
|
||||
return
|
||||
}
|
||||
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
@@ -94,7 +96,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
||||
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
|
||||
redirectAfter(w, r, pageURL(newPath))
|
||||
}
|
||||
|
||||
// canMergeMove reports whether moving onto an existing destination should
|
||||
@@ -169,7 +171,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
|
||||
return
|
||||
}
|
||||
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
|
||||
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
||||
redirectAfter(w, r, pageURL(parentURL(urlPath)))
|
||||
}
|
||||
|
||||
// normalizeMovePath returns the absolute path with any trailing slash removed,
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||
redirectAfter(w, r, pageURL(urlPath))
|
||||
}
|
||||
|
||||
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
|
||||
|
||||
@@ -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
|
||||
@@ -58,11 +61,10 @@ type pageData struct {
|
||||
Content template.HTML
|
||||
Entries []entry
|
||||
View string // listing view style: "list" or "thumbnail"
|
||||
Sort string // listing sort key: "name" / "modified" / "size"
|
||||
Order string // listing sort order: "asc" / "desc"
|
||||
SpecialContent template.HTML
|
||||
SidebarWidget template.HTML
|
||||
SuppressTOC bool
|
||||
Tree template.HTML // sidebar folder tree; empty in edit mode
|
||||
renderTimer
|
||||
}
|
||||
|
||||
@@ -130,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,
|
||||
@@ -302,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":
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -47,6 +48,7 @@ type searchPageData struct {
|
||||
PageTotal int
|
||||
FileTotal int
|
||||
IndexBuiltAt time.Time
|
||||
Tree template.HTML // sidebar folder tree
|
||||
renderTimer
|
||||
}
|
||||
|
||||
@@ -131,10 +133,11 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
PageTotal: len(pages),
|
||||
FileTotal: len(files),
|
||||
IndexBuiltAt: builtAt,
|
||||
Tree: sidebarTree(h.root, "/"),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data.renderTimer = renderTimer{requestStart(r)}
|
||||
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil {
|
||||
log.Printf("search template error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -290,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
||||
return score
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
|
||||
// the editor's link picker. Caps results at 5; reports total so the UI can
|
||||
// surface a "show all" footer when more matches exist. Empty/whitespace query
|
||||
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the typeahead: rendered rows for the dropdown
|
||||
// (header search, editor link dialog; htmx requests) and JSON for the
|
||||
// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown
|
||||
// can offer a "show all" footer (?footer) when more matches exist. An
|
||||
// empty/whitespace query is a no-op (200, no results), not a 400 — every
|
||||
// keystroke fires this.
|
||||
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAuth(w, r) {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
type suggestResp struct {
|
||||
Query string `json:"query"`
|
||||
Results []suggestResult `json:"results"`
|
||||
@@ -325,12 +331,92 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
// htmx (the typeahead dropdown) gets rendered rows; other callers (the
|
||||
// editor's [[ autocomplete) keep the JSON.
|
||||
w.Header().Set("Vary", "HX-Request")
|
||||
if isHX(r) {
|
||||
renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("search suggest encode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html"))
|
||||
|
||||
// suggestMinLen is the shortest query (in characters) the typeahead answers;
|
||||
// shorter ones render nothing, which closes the dropdown.
|
||||
const suggestMinLen = 2
|
||||
|
||||
type suggestRow struct {
|
||||
Name, Path, URL string
|
||||
Highlighted template.HTML
|
||||
}
|
||||
|
||||
// renderSuggest writes the typeahead dropdown for query. footer adds a
|
||||
// "Show all" row linking to the full results page when some were cut off.
|
||||
func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) {
|
||||
data := struct {
|
||||
Show bool
|
||||
Results []suggestRow
|
||||
Total int
|
||||
AllURL string
|
||||
}{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total}
|
||||
tokens := suggestTokens(query)
|
||||
for _, res := range results {
|
||||
data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)})
|
||||
}
|
||||
if footer && total > len(results) {
|
||||
data.AllURL = "/?q=" + url.QueryEscape(query)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil {
|
||||
log.Printf("suggest template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suggestTokens lowercases query and splits it on anything that is not a
|
||||
// letter or digit.
|
||||
func suggestTokens(query string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
}
|
||||
|
||||
// highlightMatches bolds the first occurrence of each token in name
|
||||
// (case-insensitive), merging overlapping spans, and escapes the rest.
|
||||
func highlightMatches(name string, tokens []string) template.HTML {
|
||||
lower := strings.ToLower(name)
|
||||
if len(lower) != len(name) {
|
||||
// Lowercasing changed byte lengths, so offsets would not line up.
|
||||
return template.HTML(template.HTMLEscapeString(name))
|
||||
}
|
||||
type span struct{ from, to int }
|
||||
var spans []span
|
||||
for _, t := range tokens {
|
||||
if i := strings.Index(lower, t); i >= 0 {
|
||||
spans = append(spans, span{i, i + len(t)})
|
||||
}
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from })
|
||||
var b strings.Builder
|
||||
cursor := 0
|
||||
for i := 0; i < len(spans); i++ {
|
||||
sp := spans[i]
|
||||
for i+1 < len(spans) && spans[i+1].from <= sp.to {
|
||||
i++
|
||||
sp.to = max(sp.to, spans[i].to)
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:sp.from]))
|
||||
b.WriteString("<strong>" + template.HTMLEscapeString(name[sp.from:sp.to]) + "</strong>")
|
||||
cursor = sp.to
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:]))
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// handleReindex rebuilds the folder index synchronously and returns 204.
|
||||
// The frontend reloads the page on success. Serialized via buildMu so a
|
||||
// double-click waits rather than running two walks in parallel.
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHighlightMatches(t *testing.T) {
|
||||
cases := []struct{ name, query, want string }{
|
||||
{"Project Alpha", "alpha", "Project <strong>Alpha</strong>"},
|
||||
// Overlapping and adjacent spans merge.
|
||||
{"Project Alpha", "pro ject", "<strong>Project</strong> Alpha"},
|
||||
{"Project Alpha", "roj proj", "<strong>Proj</strong>ect Alpha"},
|
||||
{"a<b>&c", "b", "a<<strong>b</strong>>&c"},
|
||||
{"Notes", "zzz", "Notes"},
|
||||
// Lowercasing changes the byte length: no highlight, still escaped.
|
||||
{"İstanbul <x>", "stan", "İstanbul <x>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := string(highlightMatches(c.name, suggestTokens(c.query))); got != c.want {
|
||||
t.Errorf("highlightMatches(%q, %q) = %q, want %q", c.name, c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,19 @@ var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type
|
||||
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
|
||||
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
|
||||
|
||||
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
|
||||
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
|
||||
// its position among task list items in source order; the disabled attribute
|
||||
// is removed so the user can toggle them.
|
||||
// rewriteTaskCheckboxes makes the task checkboxes in rendered HTML toggle
|
||||
// their source line. The disabled attribute is removed and each checkbox
|
||||
// posts itself to ?toggle=N (htmx), N being its position among task list
|
||||
// items in source order. An unchecked box sends no value, i.e. checked=false;
|
||||
// a failed save flips the box back.
|
||||
func rewriteTaskCheckboxes(in []byte) []byte {
|
||||
idx := 0
|
||||
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
|
||||
checked := bytes.Contains(match, []byte("checked"))
|
||||
var out bytes.Buffer
|
||||
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
|
||||
out.WriteString(`<input type="checkbox" class="task-checkbox" name="checked" value="true" hx-post="?toggle=`)
|
||||
out.WriteString(strconv.Itoa(idx))
|
||||
out.WriteByte('"')
|
||||
out.WriteString(`" hx-swap="none" hx-disabled-elt="this" hx-on::response-error="this.checked = !this.checked" hx-on::send-error="this.checked = !this.checked"`)
|
||||
if checked {
|
||||
out.WriteString(` checked=""`)
|
||||
}
|
||||
|
||||
@@ -1,34 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var treeTmpl = template.Must(template.New("tree").ParseFS(assets, "assets/tree.html"))
|
||||
|
||||
// treeOpts selects what a folder tree renders. The same markup serves the
|
||||
// sidebar (links) and the tree picker (radio inputs named "pick").
|
||||
type treeOpts struct {
|
||||
Files bool // list files, not just folders
|
||||
Pick string // "" renders links; "folder", "file" or "any" renders a picker for that kind
|
||||
Active string // wiki path of the current page, highlighted (sidebar)
|
||||
Select string // wiki path of the initially checked row (picker)
|
||||
}
|
||||
|
||||
// query returns the ?tree query that lazy-loads a folder's children with the
|
||||
// same options.
|
||||
func (o treeOpts) query() string {
|
||||
q := "?tree"
|
||||
if o.Files {
|
||||
q += "&files=1"
|
||||
}
|
||||
if o.Pick != "" {
|
||||
q += "&pick=" + o.Pick
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
type treeEntry struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Name string
|
||||
Kind string // "folder" or "file"
|
||||
// HasChildren reports whether a folder has any visible entries (under the
|
||||
// same foldersOnly filter), so the client can hide the expand chevron on
|
||||
// empty folders without a round-trip. Always false for files.
|
||||
HasChildren bool `json:"hasChildren"`
|
||||
// Children is populated only along the expandTo chain (see handleTree);
|
||||
// omitted otherwise so the flat picker listing keeps its original shape.
|
||||
Children []treeEntry `json:"children,omitempty"`
|
||||
// same files filter), so empty folders render without a chevron.
|
||||
HasChildren bool
|
||||
}
|
||||
|
||||
type treeResponse struct {
|
||||
Path string `json:"path"`
|
||||
Entries []treeEntry `json:"entries"`
|
||||
// treeRow is one rendered row. Folders with children are expandable: either
|
||||
// pre-expanded (Children set, along the requested path) or lazy-loaded from
|
||||
// ChildrenURL when first opened.
|
||||
type treeRow struct {
|
||||
Name, Path, Kind string
|
||||
URL string // page URL (sidebar links)
|
||||
Link bool // render the name as a link
|
||||
Selectable bool // render a picker radio
|
||||
Active, Checked bool
|
||||
Expandable bool
|
||||
Children []treeRow
|
||||
ChildrenURL string
|
||||
}
|
||||
|
||||
// handleTree responds with a JSON listing of the immediate children of the
|
||||
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not
|
||||
// descended — the client lazy-loads children on expand.
|
||||
// handleTree responds with the rendered rows of the folder at fsPath:
|
||||
//
|
||||
// ?tree sidebar rows (folders only, links)
|
||||
// &files=1 include files
|
||||
// &pick=folder|file|any picker rows selecting that kind
|
||||
// &expandTo=/a/b pre-expand the chain down to /a/b
|
||||
// &select=/a/b check that row
|
||||
// &root=1 lead with a row for "/" itself (picker)
|
||||
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||
info, err := os.Stat(fsPath)
|
||||
if err != nil {
|
||||
@@ -44,30 +81,98 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
|
||||
foldersOnly := r.URL.Query().Has("foldersOnly")
|
||||
q := r.URL.Query()
|
||||
o := treeOpts{Files: q.Has("files"), Select: q.Get("select")}
|
||||
switch p := q.Get("pick"); p {
|
||||
case "", "folder", "file", "any":
|
||||
o.Pick = p
|
||||
default:
|
||||
http.Error(w, "bad pick mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := listTreeEntries(fsPath, foldersOnly)
|
||||
dir := normalizeMovePath(urlPath)
|
||||
var expand []string
|
||||
if segs, base := treePathSegments(q.Get("expandTo")), treePathSegments(dir); len(segs) >= len(base) &&
|
||||
strings.Join(segs[:len(base)], "/") == strings.Join(base, "/") {
|
||||
expand = segs[len(base):]
|
||||
}
|
||||
rows, err := buildTreeRows(fsPath, dir, expand, o)
|
||||
if err != nil {
|
||||
http.Error(w, "read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
|
||||
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
|
||||
if dir == "/" && q.Has("root") && o.Pick != "" {
|
||||
root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o)
|
||||
rows = append([]treeRow{root}, rows...)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := treeTmpl.ExecuteTemplate(w, "rows", rows); err != nil {
|
||||
log.Printf("tree template error: %v", err)
|
||||
}
|
||||
|
||||
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// canonicalTreePath returns the URL path in the form used by the picker:
|
||||
// "/" for root, otherwise stripped of any trailing slash.
|
||||
func canonicalTreePath(urlPath string) string {
|
||||
if urlPath == "" || urlPath == "/" {
|
||||
return "/"
|
||||
// sidebarTree renders the navigation tree for the page at urlPath: the root
|
||||
// listing with the chain down to the page expanded and the page highlighted.
|
||||
// The tree is part of the page itself, so the rail never paints empty.
|
||||
func sidebarTree(root, urlPath string) template.HTML {
|
||||
page := normalizeMovePath(urlPath)
|
||||
rows, err := buildTreeRows(root, "/", treePathSegments(page), treeOpts{Active: page})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "/" + strings.Trim(urlPath, "/")
|
||||
var buf bytes.Buffer
|
||||
if err := treeTmpl.ExecuteTemplate(&buf, "rows", rows); err != nil {
|
||||
log.Printf("tree template error: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// buildTreeRows lists the folder fsPath (wiki path dir) and expands the
|
||||
// folder named by expand[0], recursing down the chain. The walk stops at the
|
||||
// deepest segment that still exists. Segments only ever match real directory
|
||||
// names from listTreeEntries, so this cannot traverse outside the tree.
|
||||
func buildTreeRows(fsPath, dir string, expand []string, o treeOpts) ([]treeRow, error) {
|
||||
entries, err := listTreeEntries(fsPath, !o.Files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]treeRow, len(entries))
|
||||
for i, e := range entries {
|
||||
p := path.Join(dir, e.Name)
|
||||
rows[i] = newTreeRow(e, p, o)
|
||||
if len(expand) > 0 && e.Kind == "folder" && e.Name == expand[0] && e.HasChildren {
|
||||
kids, err := buildTreeRows(filepath.Join(fsPath, e.Name), p, expand[1:], o)
|
||||
if err == nil {
|
||||
rows[i].Children = kids
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func newTreeRow(e treeEntry, p string, o treeOpts) treeRow {
|
||||
row := treeRow{
|
||||
Name: e.Name,
|
||||
Path: p,
|
||||
Kind: e.Kind,
|
||||
URL: pageURL(p),
|
||||
Link: o.Pick == "",
|
||||
Active: o.Pick == "" && p == o.Active,
|
||||
Expandable: e.HasChildren,
|
||||
}
|
||||
if e.Kind == "file" {
|
||||
row.URL = fileURL(strings.TrimPrefix(p, "/"))
|
||||
}
|
||||
if o.Pick != "" {
|
||||
row.Selectable = o.Pick == "any" || o.Pick == e.Kind
|
||||
row.Checked = row.Selectable && p == o.Select
|
||||
}
|
||||
if row.Expandable {
|
||||
row.ChildrenURL = row.URL + o.query()
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// listTreeEntries returns the immediate children of fsPath, filtering hidden
|
||||
@@ -139,28 +244,3 @@ func treePathSegments(p string) []string {
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
// expandTreeChain walks segs, matching each against a folder in entries by
|
||||
// name, loading that folder's children in place, and recursing. The walk stops
|
||||
// at the deepest matching segment, so a stale or deleted path simply expands as
|
||||
// far as it still exists. Segments only ever match real directory names from
|
||||
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
|
||||
// listed tree.
|
||||
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
|
||||
if len(segs) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range entries {
|
||||
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
|
||||
continue
|
||||
}
|
||||
childFs := filepath.Join(fsPath, segs[0])
|
||||
kids, err := listTreeEntries(childFs, foldersOnly)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
entries[i].Children = kids
|
||||
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleTreeExpandTo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"Projects/A/Deep", "Other/B"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(d)), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h := &handler{root: root}
|
||||
get := func(urlPath, query string) string {
|
||||
r := httptest.NewRequest("GET", urlPath+"?"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTree(w, r, urlPath, filepath.Join(root, filepath.FromSlash(strings.Trim(urlPath, "/"))))
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// A chain below the listed folder is pre-expanded.
|
||||
if body := get("/Projects/", "tree&expandTo=/Projects/A"); !strings.Contains(body, `href="/Projects/A/Deep/"`) {
|
||||
t.Errorf("expandTo below the folder did not expand:\n%s", body)
|
||||
}
|
||||
// Lazy children keep the picker options.
|
||||
if body := get("/", "tree&pick=folder&root=1"); !strings.Contains(body, `hx-get="/Projects/?tree&pick=folder"`) ||
|
||||
!strings.Contains(body, `value="/"`) {
|
||||
t.Errorf("picker rows missing lazy URL or root row:\n%s", body)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTree(w, httptest.NewRequest("GET", "/?tree&pick=bogus", nil), "/", root)
|
||||
if w.Code != 400 {
|
||||
t.Errorf("bad pick mode: got %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user