// The vault, made an editor. The page arrives whole from the server; this // adds what only a browser can: saving as you type, the reading view kept // in step with the words, [[link]] completion, the quick switcher (⌘K), // the keys, and the panels on a phone. Without it every page still reads // and the editor still saves with its button. import { render } from "./md.js"; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const mod = (e) => e.metaKey || e.ctrlKey; const narrow = () => matchMedia("(max-width: 900px)").matches; const store = { get(k) { try { return localStorage.getItem(k); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch {} }, }; /** The vault's titles, as the page last heard them. */ function vault() { try { return JSON.parse($("#vault")?.textContent ?? "[]"); } catch { return []; } } const resolver = () => { const map = new Map(vault().map((n) => [n.title.toLowerCase(), n])); return (title) => { const n = map.get(title.trim().toLowerCase()); return n ? `/n/${n.id}` : null; }; }; async function createNote(title) { const r = await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(title ? { title } : {}), }); if (r.ok) { const note = await r.json(); await go(`/n/${note.id}`); } else if (title) location.href = `/open?title=${encodeURIComponent(title)}`; } // ── Theme ───────────────────────────────────────────────────────────── document.addEventListener("click", (e) => { if (!e.target.closest?.('[data-action="theme"]')) return; const root = document.documentElement; const dark = root.dataset.theme ? root.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches; root.dataset.theme = dark ? "light" : "dark"; store.set("theme", root.dataset.theme); document.dispatchEvent(new CustomEvent("theme")); }); // ── Panels, on a narrow screen ──────────────────────────────────────── const shell = $(".shell"); function panel(which) { if (!shell) return; const open = shell.dataset.open === which ? "" : which; shell.dataset.open = open; $(".scrim").hidden = open === ""; } document.addEventListener("click", (e) => { const a = e.target.closest?.("[data-action]")?.dataset.action; if (a === "toggle-left") panel("left"); if (a === "toggle-right") panel("right"); if (a === "close-panels") panel(shell?.dataset.open ?? ""); }); // ── Small things every page does ───────────────────────────────────── for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset()); document.addEventListener("submit", (e) => { const message = e.target.dataset?.confirm; if (message && !confirm(message)) e.preventDefault(); }); // The sidebar's box filters the list as you type; Enter searches the words. function filterFiles() { const box = $("[data-filter]"); const q = (box?.value ?? "").trim().toLowerCase(); const words = q.split(/\s+/).filter((w) => w && !w.startsWith("#")); for (const a of $$(".file")) { const show = words.every((w) => a.dataset.title.includes(w)); a.parentElement.hidden = !show; } } $("[data-filter]")?.addEventListener("input", filterFiles); document.addEventListener("live:swapped", () => { filterFiles(); for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset()); }); // Links to notes not written yet make the note, then open it. document.addEventListener("click", (e) => { const a = e.target.closest?.("a.unresolved"); if (!a || mod(e) || e.button !== 0) return; e.preventDefault(); createNote(a.dataset.title); }); // ── The quick switcher ──────────────────────────────────────────────── const palette = $("#palette"); const pinput = $("#palette-input"); const plist = $("#palette-list"); let pitems = []; let psel = 0; /** A fuzzy score: every letter in order, more for runs and word starts. Null when it does not match. */ function score(title, q) { if (q === "") return 0; const t = title.toLowerCase(); if (t === q) return 1000; if (t.startsWith(q)) return 500 - t.length; const at = t.indexOf(q); if (at >= 0) return 300 - at; let s = 0; let j = 0; let run = 0; for (let i = 0; i < t.length && j < q.length; i++) { if (t[i] === q[j]) { run++; s += run * 2 + (i === 0 || /[\s\-_/]/.test(t[i - 1]) ? 6 : 0); j++; } else run = 0; } return j === q.length ? s : null; } function drawPalette() { const q = pinput.value.trim(); const ql = q.toLowerCase(); const notes = vault() .map((n) => ({ n, s: score(n.title, ql) })) .filter((x) => x.s !== null) .sort((a, b) => b.s - a.s || b.n.updated_at.localeCompare(a.n.updated_at)) .slice(0, 12); pitems = notes.map(({ n }) => ({ kind: "open", title: n.title, id: n.id })); if (q !== "" && !vault().some((n) => n.title.toLowerCase() === ql)) pitems.push({ kind: "create", title: q }); psel = Math.min(psel, Math.max(0, pitems.length - 1)); plist.innerHTML = ""; pitems.forEach((item, i) => { const li = document.createElement("li"); li.setAttribute("role", "option"); li.id = `p-${i}`; li.className = `palette-item${item.kind === "create" ? " is-create" : ""}`; li.setAttribute("aria-selected", String(i === psel)); li.textContent = item.kind === "create" ? `Create “${item.title}”` : item.title; li.addEventListener("mousedown", (e) => { e.preventDefault(); psel = i; choose(false); }); plist.append(li); }); if (pitems.length === 0) { const li = document.createElement("li"); li.className = "palette-empty muted"; li.textContent = "Type a title to create your first note"; plist.append(li); } pinput.setAttribute("aria-activedescendant", pitems.length ? `p-${psel}` : ""); $(`#p-${psel}`)?.scrollIntoView({ block: "nearest" }); } function openPalette() { if (!palette) return; palette.hidden = false; pinput.value = ""; psel = 0; drawPalette(); pinput.focus(); } function closePalette() { if (!palette || palette.hidden) return; palette.hidden = true; editorEl?.querySelector("#source")?.focus({ preventScroll: true }); } function choose(create) { const q = pinput.value.trim(); const item = create ? { kind: "create", title: q } : pitems[psel]; if (!item || !item.title) return; closePalette(); if (item.kind === "open") go(`/n/${item.id}`); else createNote(item.title); } pinput?.addEventListener("input", () => { psel = 0; drawPalette(); }); pinput?.addEventListener("keydown", (e) => { if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { e.preventDefault(); psel = (psel + 1) % Math.max(1, pitems.length); drawPalette(); } else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { e.preventDefault(); psel = (psel - 1 + pitems.length) % Math.max(1, pitems.length); drawPalette(); } else if (e.key === "Enter") { e.preventDefault(); choose(e.shiftKey); } else if (e.key === "Escape") { e.preventDefault(); closePalette(); } }); palette?.addEventListener("mousedown", (e) => { if (e.target === palette) closePalette(); }); $(".find .hint")?.addEventListener("click", openPalette); // ── Keys, everywhere ────────────────────────────────────────────────── document.addEventListener("keydown", (e) => { if (mod(e) && !e.shiftKey && (e.key === "k" || e.key === "o")) { e.preventDefault(); palette?.hidden === false ? closePalette() : openPalette(); } else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "d") { e.preventDefault(); $('form[action="/daily"]')?.requestSubmit(); } else if (mod(e) && !e.shiftKey && e.key === "g") { e.preventDefault(); go("/graph"); } else if (e.altKey && e.code === "KeyN") { e.preventDefault(); createNote(); } else if (e.key === "Escape" && shell?.dataset.open) panel(shell.dataset.open); }); // ── The editor ──────────────────────────────────────────────────────── const editorEl = $("#editor"); let dirty = false; let flush = async () => {}; /** Leave the page — once whatever is unsaved is saved. */ async function go(href) { if (dirty) await flush(); location.href = href; } document.addEventListener("click", (e) => { const a = e.target.closest?.("a[href]"); if (!a || !dirty || mod(e) || a.target === "_blank" || a.classList.contains("unresolved")) return; const url = new URL(a.href, location.href); if (url.origin !== location.origin || (url.pathname === location.pathname && url.hash)) return; e.preventDefault(); go(a.href); }); if (editorEl) editor(editorEl); function editor(form) { const id = form.dataset.id; const source = $("#source", form); const titleEl = $("#title", form); const preview = $("#preview", form); const panes = $("#panes", form); const status = $("#status"); let saved = { title: titleEl.value, body: source.value, updated: form.dataset.updated }; let timer; let saving = null; let frame = 0; // Views: edit, split (side by side, on a wide screen), read. ⌘E flips // between reading and the last way you were writing. const empty = source.value.trim() === ""; let writing = store.get("mode-writing") ?? "split"; function mode(m, remember = true) { if (m !== "read") writing = m; panes.dataset.mode = m; for (const b of $$(".segmented [data-mode]")) b.setAttribute("aria-pressed", String(b.dataset.mode === m || (m === "split" && narrow() && b.dataset.mode === "edit"))); if (remember) { store.set("mode", m); if (m !== "read") store.set("mode-writing", m); } if (m !== "read") draw(); } mode(empty ? (writing === "read" ? "edit" : writing) : (store.get("mode") ?? "split"), false); for (const b of $$(".segmented [data-mode]")) b.addEventListener("click", () => mode(b.dataset.mode)); preview.addEventListener("dblclick", (e) => { if (panes.dataset.mode !== "read" || e.target.closest("a, button")) return; mode(writing); source.focus(); }); document.addEventListener("keydown", (e) => { if (mod(e) && !e.shiftKey && e.key === "e") { e.preventDefault(); if (panes.dataset.mode === "read") { mode(writing); source.focus(); } else mode("read"); } if (mod(e) && e.key === "s") { e.preventDefault(); save(); } }); // The reading view follows the words, a frame at a time. function draw() { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { preview.innerHTML = render(source.value, { resolve: resolver() }) || '

Nothing here yet.

'; }); } // Saving: a moment after you stop, and before you leave. function say(text, kind = "") { if (!status) return; status.textContent = text; status.dataset.kind = kind; } function changed() { dirty = titleEl.value !== saved.title || source.value !== saved.body; say(dirty ? "Editing…" : "Saved"); clearTimeout(timer); if (dirty) timer = setTimeout(save, 600); } async function save() { clearTimeout(timer); if (saving) await saving; const body = {}; const t = titleEl.value.trim(); if (t !== saved.title && t !== "") body.title = t; if (source.value !== saved.body) body.body = source.value; if (Object.keys(body).length === 0) { dirty = titleEl.value.trim() !== saved.title && titleEl.value.trim() !== ""; return; } say("Saving…"); saving = (async () => { try { const r = await fetch(`/api/notes/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const answer = await r.json().catch(() => ({})); if (r.ok) { saved = { title: answer.title, body: answer.body, updated: answer.updated_at }; dirty = titleEl.value.trim() !== saved.title || source.value !== saved.body; if (body.title !== undefined) renamed(answer); say(dirty ? "Editing…" : answer.relinked ? `Saved · updated links in ${answer.relinked} ${answer.relinked === 1 ? "note" : "notes"}` : "Saved"); if (dirty) timer = setTimeout(save, 600); } else if (r.status === 422 && body.title !== undefined) { say(answer.message ?? "That title can't be used.", "error"); // Keep the words: save them under the old title. if (body.body !== undefined) { titleEl.value = saved.title; saving = null; return save(); } } else { say(answer.message ?? `Not saved (${r.status}) — retrying`, "error"); timer = setTimeout(save, 3000); } } catch { say("Offline — will retry", "error"); timer = setTimeout(save, 3000); } finally { saving = null; } })(); return saving; } flush = save; function renamed(note) { const here = $(".crumb-here"); if (here) here.textContent = note.title; document.title = document.title.replace(/^.*? · /, `${note.title} · `); } titleEl.addEventListener("input", changed); titleEl.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === "ArrowDown") { e.preventDefault(); source.focus(); source.setSelectionRange(0, 0); } }); titleEl.addEventListener("blur", () => { if (titleEl.value.trim() === "") titleEl.value = saved.title; }); form.addEventListener("submit", (e) => { e.preventDefault(); save(); }); addEventListener("beforeunload", (e) => { if (!dirty) return; // Best effort on the way out; the prompt covers what keepalive cannot carry. const body = JSON.stringify({ body: source.value, ...(titleEl.value.trim() !== saved.title && titleEl.value.trim() ? { title: titleEl.value.trim() } : {}) }); if (body.length < 60_000) fetch(`/api/notes/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body, keepalive: true }); else e.preventDefault(); }); // Someone (another tab, a script, Claude over MCP) changed this note: // take their words if you have none of your own in flight. document.addEventListener("live:note.changed", async (e) => { if (String(e.detail.id) !== id || e.detail.updated_at === saved.updated || dirty || saving) return; const r = await fetch(`/api/notes/${id}`); if (!r.ok) return; const note = await r.json(); if (dirty || note.updated_at === saved.updated) return; const at = source.selectionStart; saved = { title: note.title, body: note.body, updated: note.updated_at }; source.value = note.body; titleEl.value = note.title; renamed(note); source.setSelectionRange(at, at); draw(); say("Updated elsewhere"); }); document.addEventListener("live:note.removed", (e) => { if (String(e.detail.id) === id) { dirty = false; location.href = "/"; } }); document.addEventListener("live:swapped", () => draw()); // Typing: the words, the view, the save, and [[ completion. source.addEventListener("input", (e) => { if (e.inputType === "insertText" && e.data === "[") pairBrackets(); changed(); draw(); suggest(); }); source.addEventListener("keydown", keys); source.addEventListener("blur", () => setTimeout(hideSuggest, 120)); source.addEventListener("click", hideSuggest); // The split view scrolls together. source.addEventListener("scroll", () => { if (panes.dataset.mode !== "split" || narrow()) return; const ratio = source.scrollTop / Math.max(1, source.scrollHeight - source.clientHeight); preview.scrollTop = ratio * (preview.scrollHeight - preview.clientHeight); }); // A task's box, ticked in the reading view, ticks it in the words. preview.addEventListener("click", (e) => { const box = e.target.closest("button.check"); if (!box) return; const n = $$("button.check", preview).indexOf(box); const lines = source.value.split("\n"); let seen = -1; let fence = false; for (let i = 0; i < lines.length; i++) { if (/^(```|~~~)/.test(lines[i])) fence = !fence; if (fence) continue; const m = /^((?:\s*>)*\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\]\s)/.exec(lines[i]); if (m && ++seen === n) { lines[i] = m[1] + (m[2] === " " ? "x" : " ") + m[3] + lines[i].slice(m[0].length); source.value = lines.join("\n"); changed(); draw(); return; } } }); /** Put text in as if typed, so ⌘Z undoes it. */ function type(text, start = source.selectionStart, end = source.selectionEnd) { source.setSelectionRange(start, end); if (!document.execCommand?.("insertText", false, text)) { source.setRangeText(text, start, end, "end"); source.dispatchEvent(new Event("input")); } } function pairBrackets() { const at = source.selectionStart; const v = source.value; if (v.slice(at - 2, at) === "[[" && v.slice(at, at + 2) !== "]]") { source.setRangeText("]]", at, at, "start"); } } function wrap(mark) { const { selectionStart: a, selectionEnd: b, value } = source; const inner = value.slice(a, b); type(`${mark}${inner}${mark}`, a, b); source.setSelectionRange(a + mark.length, b + mark.length); } function keys(e) { if (suggestKeys(e)) return; const { selectionStart: a, selectionEnd: b, value } = source; const lineStart = value.lastIndexOf("\n", a - 1) + 1; const line = value.slice(lineStart, value.indexOf("\n", a) < 0 ? value.length : value.indexOf("\n", a)); if (mod(e) && e.key === "b") { e.preventDefault(); wrap("**"); } else if (mod(e) && e.key === "i") { e.preventDefault(); wrap("*"); } else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "h") { e.preventDefault(); wrap("=="); } else if (mod(e) && e.key === "Enter") { // ⌘↵ cycles a line: plain → task → done → plain. e.preventDefault(); const m = /^(\s*)(?:([-*+]|\d+[.)])\s+)?(?:\[([ xX])\]\s+)?/.exec(line); const rest = line.slice(m[0].length); const next = m[3] === undefined ? `${m[1]}- [ ] ${rest}` : m[3] === " " ? `${m[1]}${m[2] ?? "-"} [x] ${rest}` : `${m[1]}${m[2] ?? "-"} ${rest}`; type(next, lineStart, lineStart + line.length); } else if (e.key === "Enter" && !e.shiftKey && a === b) { const m = /^(\s*(?:>\s*)*)([-*+]|(\d+)([.)]))\s+(\[[ xX]\]\s+)?(.*)$/.exec(line); if (!m || a - lineStart < m[0].length - m[6].length) return; e.preventDefault(); if (m[6].trim() === "" && a === lineStart + line.length) { type("", lineStart, lineStart + line.length); // An empty item ends the list. return; } const marker = m[3] !== undefined ? `${Number(m[3]) + 1}${m[4]}` : m[2]; type(`\n${m[1]}${marker} ${m[5] ? "[ ] " : ""}`); } else if (e.key === "Tab" && !mod(e)) { e.preventDefault(); const listy = /^\s*([-*+]|\d+[.)])\s/.test(line); if (e.shiftKey) { const cut = line.match(/^( {1,2}|\t)/)?.[0].length ?? 0; if (cut) { type(line.slice(cut), lineStart, lineStart + line.length); source.setSelectionRange(Math.max(lineStart, a - cut), Math.max(lineStart, b - cut)); } } else if (listy) { type(` ${line}`, lineStart, lineStart + line.length); source.setSelectionRange(a + 2, b + 2); } else type(" "); } else if (e.key === "Escape") source.blur(); } // [[ completion: the titles that fit what you have typed so far. const box = $("#suggest"); let items = []; let sel = 0; function query() { const at = source.selectionStart; const m = /\[\[([^\[\]\n|#]*)$/.exec(source.value.slice(Math.max(0, at - 200), at)); return m ? { q: m[1], from: at - m[1].length } : null; } function suggest() { const found = query(); if (!found) return hideSuggest(); const q = found.q.toLowerCase(); const mine = saved.title.toLowerCase(); items = vault() .filter((n) => n.title.toLowerCase() !== mine) .map((n) => ({ title: n.title, s: score(n.title, q) })) .filter((x) => x.s !== null) .sort((x, y) => y.s - x.s) .slice(0, 8) .map((x) => x.title); if (found.q.trim() !== "" && !items.some((t) => t.toLowerCase() === q)) items.push(found.q.trim()); if (items.length === 0) return hideSuggest(); sel = Math.min(sel, items.length - 1); box.innerHTML = ""; const known = new Set(vault().map((n) => n.title.toLowerCase())); items.forEach((t, i) => { const li = document.createElement("li"); li.setAttribute("role", "option"); li.className = "suggest-item"; li.setAttribute("aria-selected", String(i === sel)); li.textContent = known.has(t.toLowerCase()) ? t : `${t} — new`; li.addEventListener("mousedown", (ev) => { ev.preventDefault(); sel = i; accept(); }); box.append(li); }); const { x, y } = caret(); box.hidden = false; const w = box.offsetWidth; box.style.left = `${Math.max(8, Math.min(x, innerWidth - w - 8))}px`; box.style.top = `${y + 4}px`; } function hideSuggest() { if (box) box.hidden = true; sel = 0; } function accept() { const found = query(); const title = items[sel]; if (!found || title === undefined) return; const at = source.selectionStart; const closes = source.value.slice(at, at + 2) === "]]"; type(`${title}${closes ? "" : "]]"}`, found.from, at); const end = found.from + title.length + 2; source.setSelectionRange(end, end); hideSuggest(); changed(); draw(); } function suggestKeys(e) { if (!box || box.hidden) return false; if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); sel = (sel + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length; suggest(); return true; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); accept(); return true; } if (e.key === "Escape") { e.preventDefault(); hideSuggest(); return true; } return false; } /** Where the caret is on screen: a hidden copy of the textarea, measured. */ function caret() { const style = getComputedStyle(source); const mirror = document.createElement("div"); for (const p of ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderLeftWidth", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "tabSize", "wordSpacing"]) mirror.style[p] = style[p]; Object.assign(mirror.style, { position: "absolute", visibility: "hidden", whiteSpace: "pre-wrap", overflowWrap: "break-word", top: "0", left: "-9999px" }); mirror.textContent = source.value.slice(0, source.selectionStart); const mark = document.createElement("span"); mark.textContent = "​"; mirror.append(mark); document.body.append(mirror); const r = source.getBoundingClientRect(); const x = r.left + mark.offsetLeft - source.scrollLeft; const y = r.top + mark.offsetTop - source.scrollTop + parseFloat(style.lineHeight || "24"); mirror.remove(); return { x, y }; } if (empty && document.activeElement === document.body) { if (/^Untitled( \d+)?$/.test(titleEl.value)) { titleEl.focus(); titleEl.select(); } else source.focus(); } }