todo.txt integration

This commit is contained in:
2026-07-03 11:08:27 +02:00
parent 8c9b448bdc
commit 5e72b073b8
9 changed files with 458 additions and 36 deletions
File diff suppressed because one or more lines are too long
+7 -3
View File
@@ -14,10 +14,11 @@
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
<script src="/_/tree-sidebar.js" defer></script>
<script src="/_/todo-rail.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
</head>
<body>
<body data-editor-version="{{editorBundleVersion}}">
<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>
@@ -30,7 +31,10 @@
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
</header>
<div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}}
{{if not .EditMode}}<aside class="tree-sidebar col">
<div class="todo-rail" data-todo-rail hidden></div>
<div class="tree-scroll"></div>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
+8
View File
@@ -16,6 +16,7 @@
var placeholder = null; // marks where to restore it
var prevFocus = null;
var onKeydown = null;
var onCloseCb = null; // opts.onClose of the currently hosted node
function build() {
overlay = document.createElement('div');
@@ -56,6 +57,7 @@
opts = opts || {};
prevFocus = document.activeElement;
onCloseCb = typeof opts.onClose === 'function' ? opts.onClose : null;
hosted = node;
placeholder = document.createComment('overlay-slot');
node.parentNode.insertBefore(placeholder, node);
@@ -96,6 +98,12 @@
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
// Fires after the node is back in its original spot, so the callback
// sees the final DOM (e.g. the todo rail flushing an autosave).
var cb = onCloseCb;
onCloseCb = null;
if (cb) cb();
}
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring
+68 -9
View File
@@ -99,8 +99,8 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
The shell is a full-height flex row below the header: left rail | center |
right rail. Each column owns its own vertical scroll (overflow-y:auto +
min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no
centered max-width container. Either rail collapses out of the flex flow when
empty (:empty { display:none }), letting the center reclaim the width. */
centered max-width container. The right rail collapses out of the flex flow
when empty (:empty { display:none }), letting the center reclaim the width. */
.shell {
display: flex;
min-height: 0;
@@ -465,6 +465,9 @@ body.edit-mode footer { max-width: none; }
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
button.fab { display: none; }
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
/* Todo FAB: third slot, above the tree and right-rail FABs. Only created when
todo.txt exists (todo-rail.js). */
.fab-todo { bottom: calc(var(--space-4) + 2 * (3rem + var(--space-2))); }
/* === Companion status === */
.companion-status { margin-left: auto; }
@@ -664,7 +667,7 @@ aside.sidebar:empty { display: none; }
padding: var(--space-3) var(--space-4);
-webkit-overflow-scrolling: touch;
}
.overlay-body .tree-sidebar,
.overlay-body .tree-scroll,
.overlay-body .sidebar {
display: block;
width: auto;
@@ -673,6 +676,11 @@ aside.sidebar:empty { display: none; }
padding: 0;
overflow: visible;
}
/* The todo widget fills the overlay: drop the rail divider and lift the
internal height cap so the whole file is editable on mobile. */
.overlay-body .todo-rail { border-bottom: none; padding-bottom: 0; }
.overlay-body .todo-rail .todo-rail-view,
.overlay-body .todo-rail .cm-editor { max-height: none; }
/* Right rail keeps its column spacing (TOC above widgets) inside the overlay;
the base .sidebar already sets flex-direction + gap. */
.overlay-body .sidebar { display: flex; }
@@ -723,21 +731,72 @@ aside.sidebar:empty { display: none; }
/* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
Desktop: a full-height column in the app-shell that scrolls its own
overflow. Mobile: not laid out inline — its content is surfaced through the
Overlay via .fab-tree (see responsive). */
Desktop: a full-height flex column in the app-shell (composes with .col)
holding the todo widget pinned on top and the tree's own scroll region
below. Mobile: not laid out inline — its content is surfaced through the
Overlay via the stacked FABs (see responsive). */
/* No right padding on the aside: the scrolling children below reach the
border-right so their scrollbars sit flush against the divider line (they
pad their own content off the scrollbar instead). */
.tree-sidebar {
width: var(--tree-width);
flex-shrink: 0;
overflow-y: auto;
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-4);
min-height: 0;
padding: var(--space-4) 0 var(--space-4) var(--space-4);
border-right: var(--border-dashed);
}
/* The tree's scroll region: only the tree overflows, the todo widget above
keeps its own internal scroll. Also the node the tree FAB moves into the
Overlay, so font sizing lives here rather than on the aside. */
.tree-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: var(--space-2);
font-size: var(--font-sm);
/* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave. */
user-select: none;
}
aside.tree-sidebar:empty { display: none; }
/* === Todo rail (root todo.txt widget) ===
Pinned above the folder tree (todo-rail.js). Hidden via the [hidden]
attribute until GET /_todo confirms the file exists. A long file scrolls
internally rather than pushing the tree off screen; the height cap is
lifted inside the Overlay. */
.todo-rail {
flex-shrink: 0;
padding-bottom: var(--space-3);
border-bottom: var(--border-dashed);
}
.todo-rail-head {
font-size: var(--font-xs);
margin-bottom: var(--space-2);
padding-right: var(--space-2);
}
.todo-unsaved { color: var(--primary-hover); font-size: var(--font-xs); }
/* The scrolling children run edge-to-edge (the aside has no right padding) so
their scrollbars are flush with the divider; padding-right keeps their text
clear of the scrollbar. */
.todo-rail-view {
font-family: "Iosevka Slab", monospace;
font-size: var(--font-xs);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 40vh;
overflow-y: auto;
padding-right: var(--space-2);
cursor: text;
}
.todo-rail .cm-editor { max-height: 40vh; }
.todo-rail .cm-content { padding-right: var(--space-2); }
.todo-rail .todo-fallback {
width: 100%;
min-height: 10rem;
font-family: "Iosevka Slab", monospace;
font-size: var(--font-xs);
}
/* === Movie info box === */
.movie-info { margin: var(--space-3) 0; }
+218
View File
@@ -0,0 +1,218 @@
// Root todo.txt rail widget. Surfaces wiki-root/todo.txt at the top of the
// left navigation rail (above the folder tree) and keeps it editable in place:
// a plain read view paints first (fast, no dependency), then a minimal
// CodeMirror instance (todo.txt highlighting from the bundle) always mounts —
// lazily, off the critical path, so the bundle fetch never delays first paint.
// Autosave is debounced + saves on blur via POST /_todo. If the file is absent
// (GET /_todo → 404) the widget renders nothing and no FAB is created; the
// file is only ever created out-of-band. Mobile: a dedicated todo FAB opens
// the widget in the full-viewport Overlay (overlay.js), saving on dismiss.
(function () {
var container = document.querySelector('[data-todo-rail]');
if (!container) return;
var TODO_URL = '/_todo';
var DEBOUNCE_MS = 800;
var RETRY_MS = 5000;
var view = null; // CM EditorView once mounted
var fallback = null; // <textarea> fallback if the bundle fails to load
var readView = null; // pre-focus read view
var bodyEl = null; // hosts readView, then the editor
var marker = null; // "unsaved" indicator (shown on save failure)
var lastSaved = ''; // last text confirmed written to disk
var saveTimer = null;
var retryTimer = null;
var saving = false;
var loadingBundle = false;
var wantFocus = false; // user reached for the editor before it mounted
fetch(TODO_URL, { credentials: 'same-origin' }).then(function (r) {
if (r.status === 404) return null; // no todo.txt — no widget
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
}).then(function (text) {
if (typeof text === 'string') init(text);
}).catch(function () { /* fetch failed — leave the widget hidden */ });
function init(text) {
lastSaved = text;
var head = document.createElement('div');
head.className = 'row space-between todo-rail-head';
var label = document.createElement('span');
label.className = 'muted';
label.textContent = 'todo.txt';
marker = document.createElement('span');
marker.className = 'todo-unsaved';
marker.textContent = 'unsaved';
marker.hidden = true;
head.appendChild(label);
head.appendChild(marker);
bodyEl = document.createElement('div');
readView = document.createElement('div');
readView.className = 'todo-rail-view';
readView.tabIndex = 0;
readView.textContent = text;
// If the user reaches for the read view before the idle upgrade fires,
// remember it so the mounting editor takes focus.
readView.addEventListener('focus', focusUpgrade);
readView.addEventListener('click', focusUpgrade);
bodyEl.appendChild(readView);
container.appendChild(head);
container.appendChild(bodyEl);
container.hidden = false;
setupFab();
// Always upgrade to the live editor, but only once the browser is idle
// so the CM bundle fetch never competes with first paint. The read view
// is the placeholder shown until then.
scheduleUpgrade();
}
function focusUpgrade() {
wantFocus = true;
upgrade();
}
function scheduleUpgrade() {
if (window.requestIdleCallback) {
requestIdleCallback(function () { upgrade(); }, { timeout: 2000 });
} else {
setTimeout(upgrade, 200);
}
}
// Inject the vendored CM bundle (cache-busted with the same content hash the
// edit template uses, exposed on <body>), then mount the editor. If the
// bundle cannot load (offline/VPN blip), fall back to a plain textarea so
// editing and saving still work unstyled.
function upgrade() {
if (view || fallback || loadingBundle) return;
if (window.CM) { mountEditor(); return; }
loadingBundle = true;
var v = document.body.getAttribute('data-editor-version') || '';
var s = document.createElement('script');
s.src = '/_/editor/vendor/codemirror.bundle.js' + (v ? '?v=' + v : '');
s.onload = function () { loadingBundle = false; mountEditor(); };
s.onerror = function () { loadingBundle = false; mountFallback(); };
document.head.appendChild(s);
}
function mountEditor() {
var state = CM.EditorState.create({
doc: readView.textContent,
extensions: [
CM.history(),
CM.drawSelection(),
CM.EditorView.lineWrapping,
CM.todoLanguage,
CM.syntaxHighlighting(CM.todoHighlightStyle),
CM.todoTheme,
CM.keymap.of([].concat(CM.defaultKeymap, CM.historyKeymap)),
CM.EditorView.updateListener.of(function (u) {
if (u.docChanged) scheduleSave();
}),
CM.EditorView.domEventHandlers({ blur: function () { saveNow(); } }),
],
});
bodyEl.textContent = '';
view = new CM.EditorView({ state: state, parent: bodyEl });
// Only steal focus if the user actually reached for the editor — the
// passive idle mount must not grab focus or scroll the page.
if (wantFocus) view.focus();
}
function mountFallback() {
fallback = document.createElement('textarea');
fallback.className = 'input todo-fallback';
fallback.value = readView.textContent;
fallback.addEventListener('input', scheduleSave);
fallback.addEventListener('blur', saveNow);
bodyEl.textContent = '';
bodyEl.appendChild(fallback);
if (wantFocus) fallback.focus();
}
function currentText() {
if (view) return view.state.doc.toString();
if (fallback) return fallback.value;
return readView.textContent;
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(save, DEBOUNCE_MS);
}
function saveNow() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
save();
}
// Last write wins, no conflict detection (accepted). On failure: show the
// "unsaved" marker, keep the buffer untouched, retry silently.
function save() {
saveTimer = null;
var text = currentText();
if (text === lastSaved) return;
if (saving) { scheduleSave(); return; } // serialize; re-check afterwards
saving = true;
fetch(TODO_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'content=' + encodeURIComponent(text),
}).then(function (r) {
saving = false;
if (!r.ok) throw new Error('HTTP ' + r.status);
lastSaved = text;
marker.hidden = true;
if (currentText() !== text) scheduleSave(); // typed while saving
}).catch(function () {
saving = false;
marker.hidden = false;
if (retryTimer) clearTimeout(retryTimer);
retryTimer = setTimeout(save, RETRY_MS);
});
}
// Closing the tab mid-debounce would drop the last jot; sendBeacon
// survives page teardown where fetch does not.
window.addEventListener('pagehide', function () {
var text = currentText();
if (text === lastSaved || !navigator.sendBeacon) return;
var body = new Blob(['content=' + encodeURIComponent(text)],
{ type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(TODO_URL, body);
});
// Mobile: dedicated todo FAB (above the tree FAB in the stacked group,
// created only when todo.txt exists) opens the widget in the full-viewport
// Overlay; dismissing the overlay flushes any pending save.
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';
fab.className = 'btn btn-fab fab fab-todo';
fab.title = 'Todo';
fab.setAttribute('aria-label', 'Todo');
fab.innerHTML = '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter"><path d="M2 2h12v12H2z"/><path d="M5 8.5l2 2L11 5.5"/></svg>';
fab.addEventListener('click', function () {
if (typeof openOverlay !== 'function') return;
openOverlay(container, { onClose: saveNow });
wantFocus = true;
if (view) {
// The editor may have mounted while the rail was display:none
// (measured at zero size); re-measure now that it is visible.
view.requestMeasure();
view.focus();
} else {
upgrade(); // idle mount hasn't run yet — force it, focused
}
});
document.body.appendChild(fab);
}
})();
+6 -3
View File
@@ -7,7 +7,9 @@
// omits the container).
(function () {
var container = document.querySelector('aside.tree-sidebar');
// The aside is shared with the todo widget (todo-rail.js); this script owns
// only the .tree-scroll child so neither feature clobbers the other.
var container = document.querySelector('aside.tree-sidebar .tree-scroll');
if (!container) return;
function joinPath(parent, name) {
@@ -188,8 +190,9 @@
}
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and
// opens the rail's content in the full-viewport Overlay (overlay.js). The
// rail container always exists when not editing, so — per the layout spec —
// opens the tree's scroll container (not the whole aside — the todo widget
// has its own FAB/overlay) in the full-viewport Overlay (overlay.js). The
// container always exists when not editing, so — per the layout spec —
// the FAB always renders; the overlay auto-closes when a folder/file link
// inside it navigates. Hidden on desktop by .fab CSS.
function setupFab() {