Canvas support V1
This commit is contained in:
@@ -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,83 @@
|
||||
{{/* 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}}>
|
||||
<div class="canvas-bar">
|
||||
<span class="canvas-title truncate"><a href="{{.PageURL}}">{{.PageURL}}</a>{{.Name}}</span>
|
||||
{{if not .Error}}
|
||||
<span class="canvas-tools">
|
||||
<button type="button" class="btn btn-tool" data-canvas="text" title="Add a note (or double-click the background)">TEXT</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="file" title="Add a file or page">FILE</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="link" title="Add a web link">LINK</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="group" title="Group the selection">GROUP</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Color of the selection">COLOR▾</button>
|
||||
<div class="dropdown-menu canvas-colors">
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="">None</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="1"><span class="canvas-swatch" data-color="1"></span>Red</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="2"><span class="canvas-swatch" data-color="2"></span>Orange</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="3"><span class="canvas-swatch" data-color="3"></span>Yellow</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="4"><span class="canvas-swatch" data-color="4"></span>Green</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="5"><span class="canvas-swatch" data-color="5"></span>Cyan</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="6"><span class="canvas-swatch" data-color="6"></span>Purple</button>
|
||||
</div>
|
||||
</span>
|
||||
<input type="color" class="canvas-color-input" title="Custom color of the selection" value="#c48401" />
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Arrows of the selected connections">ARROWS▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="none arrow">One way →</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="arrow arrow">Both ways ↔</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="none none">No arrows —</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="reverse">Reverse ⇄</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-canvas="undo" title="Undo (Ctrl+Z)">↶</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="redo" title="Redo (Ctrl+Y)">↷</button>
|
||||
<button type="button" class="btn btn-tool danger" data-canvas="delete" title="Delete the selection (Del)">DEL</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-canvas="zoomout" title="Zoom out">−</button>
|
||||
<button type="button" class="btn btn-tool canvas-zoom" data-canvas="zoomreset" title="Zoom to 100%">100%</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="zoomin" title="Zoom in">+</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="fit" title="Show everything">FIT</button>
|
||||
</span>
|
||||
<span class="canvas-status muted" aria-live="polite"></span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Error}}<p class="canvas-error">This canvas cannot be edited: {{.Error}}</p>{{end}}
|
||||
<div class="canvas-stage">
|
||||
<div class="canvas-node-toolbar" hidden>{{template "editor-toolbar"}}</div>
|
||||
<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</p>
|
||||
</div>
|
||||
</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"><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"><span class="truncate">{{.Host}}</span><span class="muted truncate">{{.URL}}</span></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}}
|
||||
@@ -0,0 +1,430 @@
|
||||
// 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
|
||||
// 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,
|
||||
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}}
|
||||
+15
-14
@@ -10,14 +10,26 @@
|
||||
return document.body.getAttribute('hx-boost') === 'true';
|
||||
}
|
||||
|
||||
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
||||
// query except a search (?q=). Files, the editor (?edit), internal
|
||||
// endpoints (/_…) and POST forms navigate normally.
|
||||
function isPageURL(url) {
|
||||
if (url.origin !== location.origin) return false;
|
||||
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
||||
var ok = true;
|
||||
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
|
||||
// target into the current history entry rather than pushing a new one, so
|
||||
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
|
||||
// navigation; elsewhere replaceState + reload (not location.replace: a
|
||||
// Back skips the stale pre-mutation page. Boosted pages swap a page target
|
||||
// in like a navigation; anything else (or a target that is not a page,
|
||||
// like a new canvas) gets replaceState + reload (not location.replace: a
|
||||
// target differing only by #fragment would otherwise not re-fetch).
|
||||
htmx.location = {
|
||||
set href(target) {
|
||||
if (boosting()) {
|
||||
if (boosting() && isPageURL(new URL(target, location.href))) {
|
||||
if (window.closeModal) closeModal();
|
||||
htmx.ajax('GET', target, {
|
||||
target: '.shell',
|
||||
@@ -60,17 +72,6 @@
|
||||
// snapshots that could show a page from before a move or delete).
|
||||
htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
||||
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
||||
// query except a search (?q=). Files, the editor (?edit), internal
|
||||
// endpoints (/_…) and POST forms navigate normally.
|
||||
function isPageURL(url) {
|
||||
if (url.origin !== location.origin) return false;
|
||||
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
||||
var ok = true;
|
||||
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// htmx decides per element whether it is boosted, right after this event.
|
||||
document.addEventListener('htmx:beforeProcessNode', function (e) {
|
||||
var elt = e.target;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<path d="M1 2h6v4H1zM9 10h6v4H9zM4 6v6h5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 250 B |
+3
-2
@@ -23,8 +23,9 @@
|
||||
</head>
|
||||
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell
|
||||
(plus title and page actions, see "partial") instead of loading a new
|
||||
document. hx.js decides which links count as pages. */}}
|
||||
<body{{if not .EditMode}} hx-boost="true"{{end}}>
|
||||
document. hx.js decides which links count as pages. A view can opt out by
|
||||
overriding "boost" (the canvas does: it saves on pagehide). */}}
|
||||
<body{{if not .EditMode}}{{block "boost" .}} hx-boost="true"{{end}}{{end}}>
|
||||
<header>
|
||||
<nav class="breadcrumb row">
|
||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||
|
||||
@@ -16,6 +16,19 @@ confirmation), or fail with a plain-text error. */}}
|
||||
</form>
|
||||
</div>{{end}}
|
||||
|
||||
{{define "canvas"}}<div class="modal-backdrop">
|
||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?canvas" hx-target="#modal-host">
|
||||
<div class="panel-header"><span id="modal-title">New canvas — name?</span></div>
|
||||
<div class="panel-body">
|
||||
<input class="input" type="text" name="name" placeholder="Canvas name" required autofocus />
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
||||
<button class="btn" type="submit">CREATE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>{{end}}
|
||||
|
||||
{{define "move"}}<div class="modal-backdrop">
|
||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
|
||||
<div class="panel-header"><span id="modal-title">Move — new name?</span></div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{{define "headerActions"}}{{if .CanEdit}}
|
||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
|
||||
<button class="btn btn-block" hx-get="?dialog=canvas" hx-target="#modal-host" title="New canvas on this page"><span class="btn-icon">{{icon "canvas"}}</span><span class="btn-label">CANVAS</span></button>
|
||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
|
||||
{{if not .IsRoot}}
|
||||
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
|
||||
|
||||
+243
-1
@@ -46,6 +46,15 @@
|
||||
The shell itself is edge-to-edge; this keeps prose lines readable on
|
||||
wide monitors (content stays centered within the center column). */
|
||||
--reading-width: 60rem;
|
||||
|
||||
/* JSON Canvas preset colors "1"–"6": red, orange, yellow, green, cyan,
|
||||
purple. */
|
||||
--canvas-1: #e0525b;
|
||||
--canvas-2: #e08a3a;
|
||||
--canvas-3: #d6c94d;
|
||||
--canvas-4: #5fb35b;
|
||||
--canvas-5: #01b6c4;
|
||||
--canvas-6: #a46ad6;
|
||||
}
|
||||
|
||||
/* Cross-document view transitions: page navigations stay full reloads, but the
|
||||
@@ -461,7 +470,7 @@ main > h2 {
|
||||
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
|
||||
than breaking into stacked rows. A horizontal-scroll container also clips
|
||||
overflow-y, so open dropdown menus are pinned to the viewport via JS
|
||||
(editor/main.js pinMenu) to escape the clip. */
|
||||
(editor/core.js pinMenu) to escape the clip. */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -929,6 +938,239 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; }
|
||||
}
|
||||
.fitness-tip[hidden] { display: none; }
|
||||
|
||||
/* === Canvas ===
|
||||
The canvas view (canvas/main.html) fills the shell. The world is one
|
||||
transformed layer holding nodes in canvas units; canvas.js sets its
|
||||
transform and --z (the zoom), which keeps handles and strokes a constant
|
||||
size on screen. Groups sit below the edges, everything else above. */
|
||||
.canvas-app {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.canvas-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1) var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: var(--border-dashed);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.canvas-title { min-width: 0; max-width: 30rem; }
|
||||
.canvas-title a { color: var(--link); }
|
||||
.canvas-title a:hover { color: var(--link-hover); }
|
||||
.canvas-tools { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
|
||||
.canvas-status { margin-left: auto; }
|
||||
.canvas-status.is-error { color: var(--danger); }
|
||||
.canvas-zoom { min-width: 4.5em; text-align: center; }
|
||||
.canvas-color-input {
|
||||
width: 1.6rem;
|
||||
height: 1.2rem;
|
||||
padding: 0;
|
||||
border: var(--border);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.canvas-swatch {
|
||||
display: inline-block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
margin-right: var(--space-2);
|
||||
background: var(--node-color);
|
||||
}
|
||||
.canvas-colors .btn-block { justify-content: flex-start; align-items: center; }
|
||||
.canvas-error { padding: var(--space-2) var(--space-4); color: var(--danger); }
|
||||
/* The stage holds the viewport and, floating over its top edge while a note
|
||||
is open, the page editor's toolbar, so opening it moves nothing. */
|
||||
.canvas-stage { position: relative; flex: 1 1 auto; min-height: 0; }
|
||||
.canvas-node-toolbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
||||
.canvas-node-toolbar[hidden] { display: none; }
|
||||
.canvas-node-toolbar .editor-toolbar { position: static; border-left: none; border-right: none; }
|
||||
|
||||
.canvas-viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
outline: 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: auto;
|
||||
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; }
|
||||
|
||||
/* The note editor inside a text node (editor/core.js). */
|
||||
.canvas-node.is-editing { z-index: 5; cursor: auto; user-select: text; -webkit-user-select: text; }
|
||||
.canvas-node-editor { flex: 1 1 auto; min-height: 0; display: flex; }
|
||||
.canvas-node-editor .cm-editor { flex: 1 1 auto; min-width: 0; border: none; }
|
||||
.canvas-node-editor .cm-editor .cm-scroller { min-height: 0; }
|
||||
.canvas-node-editor .cm-editor .cm-content { padding: var(--space-2) var(--space-3); }
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
.canvas-node.is-editing > .canvas-handle,
|
||||
.canvas-node.is-editing > .canvas-resize { display: none; }
|
||||
|
||||
/* 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); }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 1100px) {
|
||||
.tree-sidebar, .sidebar { display: none; }
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
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
|
||||
}
|
||||
|
||||
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})
|
||||
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
|
||||
}
|
||||
v.Markdown = true
|
||||
v.Body = template.HTML(convertMarkdown(canvasSubpath(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)
|
||||
v.Markdown = true
|
||||
v.Body = template.HTML(convertMarkdown(canvasSubpath(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 + "/"
|
||||
}
|
||||
|
||||
// canvasSubpath narrows markdown to a file node's "#Heading" subpath: that
|
||||
// heading's section including its subsections. Unknown subpaths (or block
|
||||
// references, "#^id") show the whole file.
|
||||
func canvasSubpath(raw []byte, subpath string) []byte {
|
||||
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
|
||||
if want == "" || strings.HasPrefix(want, "^") {
|
||||
return raw
|
||||
}
|
||||
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)])
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,8 @@ func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, f
|
||||
case "view":
|
||||
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
||||
renderDialog(w, "view", struct{ View, Sort, Order string }{view, sortKey, order})
|
||||
case "canvas":
|
||||
renderDialog(w, "canvas", nil)
|
||||
case "delete":
|
||||
renderDialog(w, "delete", struct{ Path string }{urlPath})
|
||||
case "move":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"))
|
||||
)
|
||||
|
||||
@@ -241,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
|
||||
@@ -267,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>
|
||||
@@ -474,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
|
||||
|
||||
@@ -7,14 +7,17 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
var md goldmark.Markdown
|
||||
@@ -129,19 +132,61 @@ var (
|
||||
iconAudio = readIcon("audio")
|
||||
iconArchive = readIcon("archive")
|
||||
iconGeneric = readIcon("generic")
|
||||
iconCanvas = readIcon("canvas")
|
||||
)
|
||||
|
||||
// renderMarkdown converts raw markdown to trusted HTML.
|
||||
func renderMarkdown(raw []byte) template.HTML {
|
||||
return template.HTML(rewriteTaskCheckboxes(convertMarkdown(raw, "")))
|
||||
}
|
||||
|
||||
// convertMarkdown renders raw markdown to HTML with the wiki's table styling
|
||||
// but leaves task checkboxes as goldmark emits them (disabled), so callers
|
||||
// decide how they toggle.
|
||||
//
|
||||
// base, when set, is the URL path (slash-terminated) relative link and embed
|
||||
// destinations resolve against. Pages leave it empty: the browser resolves
|
||||
// them against the page URL. Markdown shown somewhere else (a canvas file
|
||||
// node showing another folder's page) passes that folder.
|
||||
func convertMarkdown(raw []byte, base string) []byte {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(raw, &buf); err != nil {
|
||||
return ""
|
||||
doc := md.Parser().Parse(text.NewReader(raw))
|
||||
if base != "" {
|
||||
rebaseDestinations(doc, base)
|
||||
}
|
||||
if err := md.Renderer().Render(&buf, raw, doc); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := rewriteTaskCheckboxes(buf.Bytes())
|
||||
// Goldmark emits a bare `<table>`; tag it so it picks up the shared
|
||||
// .data-table styling with the grid modifier (per-cell borders + header).
|
||||
out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
|
||||
return template.HTML(out)
|
||||
return bytes.ReplaceAll(buf.Bytes(), []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
|
||||
}
|
||||
|
||||
// schemeRe matches a URL that starts with a scheme ("https:", "mailto:").
|
||||
var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
|
||||
|
||||
// rebaseDestinations prefixes every relative link and image destination in
|
||||
// doc with base. Absolute paths, fragments, queries and URLs with a scheme
|
||||
// stay as they are.
|
||||
func rebaseDestinations(doc ast.Node, base string) {
|
||||
rebase := func(dest []byte) []byte {
|
||||
if len(dest) == 0 || dest[0] == '/' || dest[0] == '#' || dest[0] == '?' || schemeRe.Match(dest) {
|
||||
return dest
|
||||
}
|
||||
return append([]byte(base), dest...)
|
||||
}
|
||||
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch n := n.(type) {
|
||||
case *ast.Link:
|
||||
n.Destination = rebase(n.Destination)
|
||||
case *ast.Image:
|
||||
n.Destination = rebase(n.Destination)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
|
||||
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
||||
@@ -301,6 +346,8 @@ func fileIcon(name string) template.HTML {
|
||||
switch ext {
|
||||
case ".md", ".pdf":
|
||||
return iconDoc
|
||||
case canvasExt:
|
||||
return iconCanvas
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
||||
return iconImage
|
||||
case ".mp4", ".mkv", ".avi", ".mov":
|
||||
|
||||
Reference in New Issue
Block a user