354 lines
15 KiB
JavaScript
354 lines
15 KiB
JavaScript
// 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. While the tab is
|
|
// visible the widget polls /_todo (conditional GET, ETag) and reloads when
|
|
// another device changed the file — but only while the local buffer is clean,
|
|
// so an active typist is never interrupted (last-write-wins on a real clash).
|
|
// 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 POLL_MS = 15000; // external-change check cadence (visible tab only)
|
|
|
|
var view = null; // CM EditorView once mounted
|
|
var fallback = null; // <textarea> fallback if the bundle fails to load
|
|
var readView = null; // placeholder shown until the editor mounts
|
|
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
|
|
var knownETag = null; // ETag of the content currently in the buffer
|
|
var pendingAction = null; // toolbar action clicked before the editor mounted
|
|
|
|
fetch(TODO_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (r) {
|
|
if (r.status === 404) return null; // no todo.txt — no widget
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
knownETag = r.headers.get('ETag');
|
|
return r.text();
|
|
}).then(function (text) {
|
|
if (typeof text === 'string') { init(text); startPolling(); }
|
|
}).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';
|
|
|
|
// Toolbar replaces the old static "todo.txt" label. Each button acts on
|
|
// the current line of the editor via runAction; the labels render
|
|
// bracketed ([done] [pri] …) to match the app's button aesthetic.
|
|
var toolbar = document.createElement('div');
|
|
toolbar.className = 'row gap-1 todo-toolbar';
|
|
toolbar.appendChild(toolButton('done', 'Toggle done (x) on this line', actions.done));
|
|
toolbar.appendChild(toolButton('pri', 'Cycle priority (A → B → C → none)', actions.priority));
|
|
toolbar.appendChild(toolButton('date', "Insert today's date at the cursor", actions.date));
|
|
toolbar.appendChild(toolButton('del', 'Delete this line', actions.del, 'danger'));
|
|
|
|
marker = document.createElement('span');
|
|
marker.className = 'todo-unsaved';
|
|
marker.textContent = 'unsaved';
|
|
marker.hidden = true;
|
|
head.appendChild(toolbar);
|
|
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 placeholder (click or keyboard tab both
|
|
// focus it — tabIndex 0) before the idle upgrade fires, mount the editor
|
|
// now and let it take focus so they can type without waiting.
|
|
readView.addEventListener('focus', 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();
|
|
// A toolbar button clicked during the mount runs now that the view exists.
|
|
if (pendingAction) { var p = pendingAction; pendingAction = null; p(view); }
|
|
}
|
|
|
|
function mountFallback() {
|
|
pendingAction = null; // the plain textarea can't run CM-based actions
|
|
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();
|
|
}
|
|
|
|
// --- Toolbar ---------------------------------------------------------
|
|
|
|
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
|
|
|
// Local ISO date (YYYY-MM-DD), matching the wiki's date convention.
|
|
function todayISO() {
|
|
var d = new Date();
|
|
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
|
}
|
|
|
|
function mainLine(v) { return v.state.doc.lineAt(v.state.selection.main.head); }
|
|
|
|
// Each action operates on the current line of the CM view. Kept deliberately
|
|
// simple (string edits at the line start / cursor) — no task parsing, per
|
|
// the todo.txt widget's highlighting-only remit.
|
|
var actions = {
|
|
// Toggle the `x ` done prefix (with completion date). Stripping also
|
|
// removes the optional date that followed it.
|
|
done: function (v) {
|
|
var line = mainLine(v);
|
|
var m = /^x (?:\d{4}-\d{2}-\d{2} )?/.exec(line.text);
|
|
if (m) {
|
|
v.dispatch({ changes: { from: line.from, to: line.from + m[0].length } });
|
|
} else {
|
|
v.dispatch({ changes: { from: line.from, insert: 'x ' + todayISO() + ' ' } });
|
|
}
|
|
},
|
|
// Cycle the leading priority: none → (A) → (B) → (C) → none.
|
|
priority: function (v) {
|
|
var line = mainLine(v);
|
|
var m = /^\(([A-Z])\) /.exec(line.text);
|
|
var next = !m ? '(A) ' : m[1] === 'A' ? '(B) ' : m[1] === 'B' ? '(C) ' : '';
|
|
v.dispatch({ changes: { from: line.from, to: line.from + (m ? m[0].length : 0), insert: next } });
|
|
},
|
|
// Insert today's date at the cursor.
|
|
date: function (v) {
|
|
v.dispatch(v.state.replaceSelection(todayISO() + ' '));
|
|
},
|
|
del: function (v) { CM.deleteLine(v); },
|
|
};
|
|
|
|
function runAction(fn) {
|
|
if (view) { fn(view); view.focus(); return; }
|
|
if (fallback) return; // unstyled fallback has no CM view to act on
|
|
// Editor still mounting — remember the action and mount it now, focused.
|
|
pendingAction = fn;
|
|
wantFocus = true;
|
|
upgrade();
|
|
}
|
|
|
|
function toolButton(label, title, fn, extraClass) {
|
|
var b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.className = 'btn btn-tool' + (extraClass ? ' ' + extraClass : '');
|
|
b.textContent = label;
|
|
b.title = title;
|
|
// Keep the editor focused (and the mobile keyboard up) when tapping the
|
|
// toolbar, mirroring the page editor — preventDefault blocks the focus
|
|
// shift on mousedown; the click still fires.
|
|
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
|
b.addEventListener('click', function () { runAction(fn); });
|
|
return b;
|
|
}
|
|
|
|
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;
|
|
// Adopt the server's new ETag so our own write polls back as 304
|
|
// instead of reloading itself as an "external" change.
|
|
knownETag = r.headers.get('ETag') || knownETag;
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Replace the buffer with content another device wrote. Only ever called
|
|
// when the local buffer is clean (see poll), so nothing unsaved is lost.
|
|
function applyExternal(text, etag) {
|
|
knownETag = etag;
|
|
lastSaved = text;
|
|
if (view) {
|
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
|
|
} else if (fallback) {
|
|
fallback.value = text;
|
|
} else if (readView) {
|
|
readView.textContent = text; // editor not mounted yet — reseed the placeholder
|
|
}
|
|
marker.hidden = true;
|
|
}
|
|
|
|
// Conditional GET: 304 means unchanged (cheap), 200 means another device
|
|
// wrote the file. Skip while hidden, while our own save is in flight, or
|
|
// while the buffer is dirty — a real concurrent clash resolves last-write-
|
|
// wins when this device saves, and the other device polls the result.
|
|
function poll() {
|
|
if (document.visibilityState !== 'visible') return;
|
|
if (saving || saveTimer) return;
|
|
if (currentText() !== lastSaved) return;
|
|
fetch(TODO_URL, {
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
headers: knownETag ? { 'If-None-Match': knownETag } : {},
|
|
}).then(function (r) {
|
|
if (r.status === 304 || !r.ok) return; // unchanged, or a transient error/404
|
|
var etag = r.headers.get('ETag');
|
|
return r.text().then(function (text) {
|
|
// Re-check: the user may have started typing during the fetch.
|
|
if (!saving && !saveTimer && currentText() === lastSaved) {
|
|
applyExternal(text, etag);
|
|
}
|
|
});
|
|
}).catch(function () { /* offline blip — retry next cycle */ });
|
|
}
|
|
|
|
function startPolling() {
|
|
setInterval(poll, POLL_MS);
|
|
// Coming back to the tab/window is the moment a device switch matters
|
|
// most, so refresh immediately instead of waiting for the next tick.
|
|
document.addEventListener('visibilitychange', function () {
|
|
if (document.visibilityState === 'visible') poll();
|
|
});
|
|
window.addEventListener('focus', poll);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
})();
|