todo.txt integration
This commit is contained in:
+17
-15
File diff suppressed because one or more lines are too long
+7
-3
@@ -14,10 +14,11 @@
|
|||||||
<script src="/_/tree-picker.js"></script>
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="/_/companion.js" defer></script>
|
<script src="/_/companion.js" defer></script>
|
||||||
{{if not .EditMode}}<script src="/_/overlay.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}}
|
{{block "headScripts" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-editor-version="{{editorBundleVersion}}">
|
||||||
<header>
|
<header>
|
||||||
<nav class="breadcrumb row">
|
<nav class="breadcrumb row">
|
||||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||||
@@ -30,7 +31,10 @@
|
|||||||
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="shell">
|
<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">
|
<div class="center">
|
||||||
<main>
|
<main>
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
var placeholder = null; // marks where to restore it
|
var placeholder = null; // marks where to restore it
|
||||||
var prevFocus = null;
|
var prevFocus = null;
|
||||||
var onKeydown = null;
|
var onKeydown = null;
|
||||||
|
var onCloseCb = null; // opts.onClose of the currently hosted node
|
||||||
|
|
||||||
function build() {
|
function build() {
|
||||||
overlay = document.createElement('div');
|
overlay = document.createElement('div');
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
|
||||||
prevFocus = document.activeElement;
|
prevFocus = document.activeElement;
|
||||||
|
onCloseCb = typeof opts.onClose === 'function' ? opts.onClose : null;
|
||||||
hosted = node;
|
hosted = node;
|
||||||
placeholder = document.createComment('overlay-slot');
|
placeholder = document.createComment('overlay-slot');
|
||||||
node.parentNode.insertBefore(placeholder, node);
|
node.parentNode.insertBefore(placeholder, node);
|
||||||
@@ -96,6 +98,12 @@
|
|||||||
if (toRestore && toRestore.focus) {
|
if (toRestore && toRestore.focus) {
|
||||||
try { toRestore.focus(); } catch (e) {}
|
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
|
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring
|
||||||
|
|||||||
+68
-9
@@ -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 |
|
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 +
|
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
|
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
|
centered max-width container. The right rail collapses out of the flex flow
|
||||||
empty (:empty { display:none }), letting the center reclaim the width. */
|
when empty (:empty { display:none }), letting the center reclaim the width. */
|
||||||
.shell {
|
.shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
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; }
|
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
|
||||||
button.fab { display: none; }
|
button.fab { display: none; }
|
||||||
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
|
.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 === */
|
||||||
.companion-status { margin-left: auto; }
|
.companion-status { margin-left: auto; }
|
||||||
@@ -664,7 +667,7 @@ aside.sidebar:empty { display: none; }
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.overlay-body .tree-sidebar,
|
.overlay-body .tree-scroll,
|
||||||
.overlay-body .sidebar {
|
.overlay-body .sidebar {
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -673,6 +676,11 @@ aside.sidebar:empty { display: none; }
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: visible;
|
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;
|
/* Right rail keeps its column spacing (TOC above widgets) inside the overlay;
|
||||||
the base .sidebar already sets flex-direction + gap. */
|
the base .sidebar already sets flex-direction + gap. */
|
||||||
.overlay-body .sidebar { display: flex; }
|
.overlay-body .sidebar { display: flex; }
|
||||||
@@ -723,21 +731,72 @@ aside.sidebar:empty { display: none; }
|
|||||||
|
|
||||||
/* === Tree sidebar (persistent left navigation rail) ===
|
/* === Tree sidebar (persistent left navigation rail) ===
|
||||||
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
|
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
|
||||||
Desktop: a full-height column in the app-shell that scrolls its own
|
Desktop: a full-height flex column in the app-shell (composes with .col)
|
||||||
overflow. Mobile: not laid out inline — its content is surfaced through the
|
holding the todo widget pinned on top and the tree's own scroll region
|
||||||
Overlay via .fab-tree (see responsive). */
|
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 {
|
.tree-sidebar {
|
||||||
width: var(--tree-width);
|
width: var(--tree-width);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow-y: auto;
|
min-height: 0;
|
||||||
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-4);
|
padding: var(--space-4) 0 var(--space-4) var(--space-4);
|
||||||
border-right: var(--border-dashed);
|
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);
|
font-size: var(--font-sm);
|
||||||
/* Rows are click targets (navigate / toggle), not prose — suppress the
|
/* Rows are click targets (navigate / toggle), not prose — suppress the
|
||||||
text-selection highlight that double/drag clicks would otherwise leave. */
|
text-selection highlight that double/drag clicks would otherwise leave. */
|
||||||
user-select: none;
|
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 box === */
|
||||||
.movie-info { margin: var(--space-3) 0; }
|
.movie-info { margin: var(--space-3) 0; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
// omits the container).
|
// omits the container).
|
||||||
|
|
||||||
(function () {
|
(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;
|
if (!container) return;
|
||||||
|
|
||||||
function joinPath(parent, name) {
|
function joinPath(parent, name) {
|
||||||
@@ -188,8 +190,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and
|
// 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
|
// opens the tree's scroll container (not the whole aside — the todo widget
|
||||||
// rail container always exists when not editing, so — per the layout spec —
|
// 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
|
// the FAB always renders; the overlay auto-closes when a folder/file link
|
||||||
// inside it navigates. Hidden on desktop by .fab CSS.
|
// inside it navigates. Hidden on desktop by .fab CSS.
|
||||||
function setupFab() {
|
function setupFab() {
|
||||||
|
|||||||
+61
-1
@@ -8,7 +8,7 @@ import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/sta
|
|||||||
import { EditorView, keymap, drawSelection } from "@codemirror/view";
|
import { EditorView, keymap, drawSelection } from "@codemirror/view";
|
||||||
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
|
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
|
||||||
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
|
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
|
||||||
import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
|
import { syntaxHighlighting, HighlightStyle, indentOnInput, StreamLanguage } from "@codemirror/language";
|
||||||
import {
|
import {
|
||||||
autocompletion,
|
autocompletion,
|
||||||
closeBrackets,
|
closeBrackets,
|
||||||
@@ -77,6 +77,63 @@ const highlightStyle = HighlightStyle.define([
|
|||||||
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
|
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// todo.txt language for the left-rail todo widget (assets/todo-rail.js).
|
||||||
|
// Line-oriented: `x ` done-prefix strikes the whole line; `(A)` priority,
|
||||||
|
// `+project`, `@context` and ISO dates get their own colors. No parsing into
|
||||||
|
// task objects — highlighting only.
|
||||||
|
const todoLanguage = StreamLanguage.define({
|
||||||
|
token(stream) {
|
||||||
|
if (stream.sol() && stream.match(/^x\s.*/)) return "todoDone";
|
||||||
|
if (stream.sol() && stream.match(/^\([A-Z]\)(?=\s|$)/)) return "todoPriority";
|
||||||
|
// +project / @context / dates only count at word starts (start of line
|
||||||
|
// or after whitespace), per the todo.txt format.
|
||||||
|
const atWordStart = stream.start === 0 || /\s/.test(stream.string.charAt(stream.start - 1));
|
||||||
|
if (atWordStart) {
|
||||||
|
if (stream.match(/^\d{4}-\d{2}-\d{2}(?=\s|$)/)) return "todoDate";
|
||||||
|
if (stream.match(/^\+\S+/)) return "todoProject";
|
||||||
|
if (stream.match(/^@\S+/)) return "todoContext";
|
||||||
|
}
|
||||||
|
// Consume whitespace OR one plain word — never both, or the word after
|
||||||
|
// a space would be swallowed before it can be matched above.
|
||||||
|
if (!stream.eatSpace()) {
|
||||||
|
stream.next();
|
||||||
|
stream.eatWhile(/\S/);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
tokenTable: {
|
||||||
|
todoDone: tags.strikethrough,
|
||||||
|
todoPriority: tags.keyword,
|
||||||
|
todoProject: tags.typeName,
|
||||||
|
todoContext: tags.atom,
|
||||||
|
todoDate: tags.meta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const todoHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.strikethrough, textDecoration: "line-through", color: "var(--text-muted)" },
|
||||||
|
{ tag: tags.keyword, color: "var(--secondary)", fontWeight: "bold" },
|
||||||
|
{ tag: tags.typeName, color: "var(--link)" },
|
||||||
|
{ tag: tags.atom, color: "var(--primary-hover)" },
|
||||||
|
{ tag: tags.meta, color: "var(--text-muted)" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Minimal chrome for the rail editor — the full editor `theme` above forces a
|
||||||
|
// 60vh min-height and page-editor padding that don't fit a sidebar widget.
|
||||||
|
// Sizing/height caps live in style.css under .todo-rail.
|
||||||
|
const todoTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": { backgroundColor: "transparent", color: "var(--text)", fontSize: "var(--font-xs)" },
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
".cm-scroller": { fontFamily: '"Iosevka Slab", monospace', lineHeight: "1.5" },
|
||||||
|
".cm-content": { caretColor: "var(--text)" },
|
||||||
|
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
|
||||||
|
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||||
|
{ backgroundColor: "var(--bg-panel-hover)" },
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
);
|
||||||
|
|
||||||
window.CM = {
|
window.CM = {
|
||||||
EditorState,
|
EditorState,
|
||||||
EditorSelection,
|
EditorSelection,
|
||||||
@@ -104,4 +161,7 @@ window.CM = {
|
|||||||
startCompletion,
|
startCompletion,
|
||||||
theme,
|
theme,
|
||||||
highlightStyle,
|
highlightStyle,
|
||||||
|
todoLanguage,
|
||||||
|
todoHighlightStyle,
|
||||||
|
todoTheme,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,12 +36,17 @@ func hashAsset(name string) string {
|
|||||||
return hex.EncodeToString(sum[:])[:12]
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tmplFuncs is shared by every layout-based template: layout.html renders the
|
||||||
|
// bundle version as a data attribute on <body> so global JS (todo-rail.js) can
|
||||||
|
// cache-bust its lazy bundle load the same way the edit template does.
|
||||||
|
var tmplFuncs = template.FuncMap{
|
||||||
|
"editorBundleVersion": func() string { return editorBundleVersion },
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||||
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
|
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||||
"editorBundleVersion": func() string { return editorBundleVersion },
|
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
||||||
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
|
||||||
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// specialPage is the result returned by a pageTypeHandler.
|
// specialPage is the result returned by a pageTypeHandler.
|
||||||
@@ -119,6 +124,7 @@ func main() {
|
|||||||
http.HandleFunc("/_logout", h.handleLogout)
|
http.HandleFunc("/_logout", h.handleLogout)
|
||||||
http.HandleFunc("/_reindex", h.handleReindex)
|
http.HandleFunc("/_reindex", h.handleReindex)
|
||||||
http.HandleFunc("/_search", h.handleSearchSuggest)
|
http.HandleFunc("/_search", h.handleSearchSuggest)
|
||||||
|
http.HandleFunc("/_todo", h.handleTodo)
|
||||||
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
||||||
http.Handle("/", h)
|
http.Handle("/", h)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Root todo.txt rail widget (assets/todo-rail.js). /_todo reads and writes the
|
||||||
|
// single wiki-root todo.txt — the path is fixed, so there is no traversal
|
||||||
|
// surface. GET returns the raw text (404 when the file is absent, which the
|
||||||
|
// widget treats as "render nothing"); POST replaces the whole file atomically.
|
||||||
|
// Unlike the index.md save path, empty content does NOT delete the file: the
|
||||||
|
// widget has no in-UI way to recreate it, so an empty save keeps an empty file.
|
||||||
|
|
||||||
|
func (h *handler) todoPath() string {
|
||||||
|
return filepath.Join(h.root, "todo.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleTodo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.checkAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.handleTodoGet(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
h.handleTodoPost(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleTodoGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := os.ReadFile(h.todoPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The rail re-fetches on every page load; a heuristically cached copy
|
||||||
|
// would show stale todos after an edit.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleTodoPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content := r.FormValue("content")
|
||||||
|
if err := writeFileAtomic(h.todoPath(), []byte(content), 0644); err != nil {
|
||||||
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user