todo.txt integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user