| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
import { render } from "./md.js"; |
| 8 |
|
| 9 |
const $ = (sel, root = document) => root.querySelector(sel); |
| 10 |
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; |
| 11 |
const mod = (e) => e.metaKey || e.ctrlKey; |
| 12 |
const narrow = () => matchMedia("(max-width: 900px)").matches; |
| 13 |
const store = { |
| 14 |
get(k) { |
| 15 |
try { |
| 16 |
return localStorage.getItem(k); |
| 17 |
} catch { |
| 18 |
return null; |
| 19 |
} |
| 20 |
}, |
| 21 |
set(k, v) { |
| 22 |
try { |
| 23 |
localStorage.setItem(k, v); |
| 24 |
} catch {} |
| 25 |
}, |
| 26 |
}; |
| 27 |
|
| 28 |
|
| 29 |
const base = $("#vault")?.dataset.base ?? ""; |
| 30 |
const space = $("#vault")?.dataset.space; |
| 31 |
|
| 32 |
const folder = () => $('form[action$="/new"] input[name="folder"]')?.value ?? ""; |
| 33 |
|
| 34 |
|
| 35 |
function vault() { |
| 36 |
try { |
| 37 |
return JSON.parse($("#vault")?.textContent ?? "[]"); |
| 38 |
} catch { |
| 39 |
return []; |
| 40 |
} |
| 41 |
} |
| 42 |
const resolver = () => { |
| 43 |
const map = new Map(vault().map((n) => [n.title.toLowerCase(), n])); |
| 44 |
return (title) => { |
| 45 |
const n = map.get(title.trim().toLowerCase()); |
| 46 |
return n ? `/n/${n.id}` : null; |
| 47 |
}; |
| 48 |
}; |
| 49 |
|
| 50 |
async function createNote(title) { |
| 51 |
const r = await fetch("/api/notes", { |
| 52 |
method: "POST", |
| 53 |
headers: { "content-type": "application/json" }, |
| 54 |
body: JSON.stringify({ |
| 55 |
...(title ? { title } : {}), |
| 56 |
...(space ? { space: Number(space), folder: folder() } : {}), |
| 57 |
}), |
| 58 |
}); |
| 59 |
if (r.ok) { |
| 60 |
const note = await r.json(); |
| 61 |
await go(`/n/${note.id}`); |
| 62 |
} else if (title) location.href = `${base}/open?title=${encodeURIComponent(title)}`; |
| 63 |
} |
| 64 |
|
| 65 |
|
| 66 |
document.addEventListener("click", (e) => { |
| 67 |
if (!e.target.closest?.('[data-action="theme"]')) return; |
| 68 |
const root = document.documentElement; |
| 69 |
const dark = root.dataset.theme |
| 70 |
? root.dataset.theme === "dark" |
| 71 |
: matchMedia("(prefers-color-scheme: dark)").matches; |
| 72 |
root.dataset.theme = dark ? "light" : "dark"; |
| 73 |
store.set("theme", root.dataset.theme); |
| 74 |
document.dispatchEvent(new CustomEvent("theme")); |
| 75 |
}); |
| 76 |
|
| 77 |
|
| 78 |
const shell = $(".shell"); |
| 79 |
function panel(which) { |
| 80 |
if (!shell) return; |
| 81 |
const open = shell.dataset.open === which ? "" : which; |
| 82 |
shell.dataset.open = open; |
| 83 |
$(".scrim").hidden = open === ""; |
| 84 |
} |
| 85 |
document.addEventListener("click", (e) => { |
| 86 |
const a = e.target.closest?.("[data-action]")?.dataset.action; |
| 87 |
if (a === "toggle-left") panel("left"); |
| 88 |
if (a === "toggle-right") panel("right"); |
| 89 |
if (a === "close-panels") panel(shell?.dataset.open ?? ""); |
| 90 |
}); |
| 91 |
|
| 92 |
|
| 93 |
|
| 94 |
document.addEventListener("click", (e) => { |
| 95 |
const box = e.target.closest?.("[data-select]"); |
| 96 |
if (!box) return; |
| 97 |
box.select(); |
| 98 |
navigator.clipboard?.writeText(box.value).then(() => { |
| 99 |
box.dataset.copied = "true"; |
| 100 |
}, () => {}); |
| 101 |
}); |
| 102 |
for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset()); |
| 103 |
document.addEventListener("submit", (e) => { |
| 104 |
const message = e.target.dataset?.confirm; |
| 105 |
if (message && !confirm(message)) e.preventDefault(); |
| 106 |
}); |
| 107 |
|
| 108 |
|
| 109 |
function filterFiles() { |
| 110 |
const box = $("[data-filter]"); |
| 111 |
const q = (box?.value ?? "").trim().toLowerCase(); |
| 112 |
const words = q.split(/\s+/).filter((w) => w && !w.startsWith("#")); |
| 113 |
for (const a of $$(".file")) { |
| 114 |
const show = words.every((w) => a.dataset.title.includes(w)); |
| 115 |
a.parentElement.hidden = !show; |
| 116 |
} |
| 117 |
} |
| 118 |
$("[data-filter]")?.addEventListener("input", filterFiles); |
| 119 |
document.addEventListener("live:swapped", () => { |
| 120 |
filterFiles(); |
| 121 |
for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset()); |
| 122 |
}); |
| 123 |
|
| 124 |
|
| 125 |
document.addEventListener("click", (e) => { |
| 126 |
const a = e.target.closest?.("a.unresolved"); |
| 127 |
if (!a || mod(e) || e.button !== 0) return; |
| 128 |
e.preventDefault(); |
| 129 |
createNote(a.dataset.title); |
| 130 |
}); |
| 131 |
|
| 132 |
|
| 133 |
const palette = $("#palette"); |
| 134 |
const pinput = $("#palette-input"); |
| 135 |
const plist = $("#palette-list"); |
| 136 |
let pitems = []; |
| 137 |
let psel = 0; |
| 138 |
|
| 139 |
|
| 140 |
function score(title, q) { |
| 141 |
if (q === "") return 0; |
| 142 |
const t = title.toLowerCase(); |
| 143 |
if (t === q) return 1000; |
| 144 |
if (t.startsWith(q)) return 500 - t.length; |
| 145 |
const at = t.indexOf(q); |
| 146 |
if (at >= 0) return 300 - at; |
| 147 |
let s = 0; |
| 148 |
let j = 0; |
| 149 |
let run = 0; |
| 150 |
for (let i = 0; i < t.length && j < q.length; i++) { |
| 151 |
if (t[i] === q[j]) { |
| 152 |
run++; |
| 153 |
s += run * 2 + (i === 0 || /[\s\-_/]/.test(t[i - 1]) ? 6 : 0); |
| 154 |
j++; |
| 155 |
} else run = 0; |
| 156 |
} |
| 157 |
return j === q.length ? s : null; |
| 158 |
} |
| 159 |
|
| 160 |
function drawPalette() { |
| 161 |
const q = pinput.value.trim(); |
| 162 |
const ql = q.toLowerCase(); |
| 163 |
const notes = vault() |
| 164 |
.map((n) => ({ n, s: score(n.title, ql) })) |
| 165 |
.filter((x) => x.s !== null) |
| 166 |
.sort((a, b) => b.s - a.s || b.n.updated_at.localeCompare(a.n.updated_at)) |
| 167 |
.slice(0, 12); |
| 168 |
pitems = notes.map(({ n }) => ({ kind: "open", title: n.title, id: n.id })); |
| 169 |
if (q !== "" && !vault().some((n) => n.title.toLowerCase() === ql)) pitems.push({ kind: "create", title: q }); |
| 170 |
psel = Math.min(psel, Math.max(0, pitems.length - 1)); |
| 171 |
plist.innerHTML = ""; |
| 172 |
pitems.forEach((item, i) => { |
| 173 |
const li = document.createElement("li"); |
| 174 |
li.setAttribute("role", "option"); |
| 175 |
li.id = `p-${i}`; |
| 176 |
li.className = `palette-item${item.kind === "create" ? " is-create" : ""}`; |
| 177 |
li.setAttribute("aria-selected", String(i === psel)); |
| 178 |
li.textContent = item.kind === "create" ? `Create “${item.title}”` : item.title; |
| 179 |
li.addEventListener("mousedown", (e) => { |
| 180 |
e.preventDefault(); |
| 181 |
psel = i; |
| 182 |
choose(false); |
| 183 |
}); |
| 184 |
plist.append(li); |
| 185 |
}); |
| 186 |
if (pitems.length === 0) { |
| 187 |
const li = document.createElement("li"); |
| 188 |
li.className = "palette-empty muted"; |
| 189 |
li.textContent = "Type a title to create your first note"; |
| 190 |
plist.append(li); |
| 191 |
} |
| 192 |
pinput.setAttribute("aria-activedescendant", pitems.length ? `p-${psel}` : ""); |
| 193 |
$(`#p-${psel}`)?.scrollIntoView({ block: "nearest" }); |
| 194 |
} |
| 195 |
function openPalette() { |
| 196 |
if (!palette) return; |
| 197 |
palette.hidden = false; |
| 198 |
pinput.value = ""; |
| 199 |
psel = 0; |
| 200 |
drawPalette(); |
| 201 |
pinput.focus(); |
| 202 |
} |
| 203 |
function closePalette() { |
| 204 |
if (!palette || palette.hidden) return; |
| 205 |
palette.hidden = true; |
| 206 |
editorEl?.querySelector("#source")?.focus({ preventScroll: true }); |
| 207 |
} |
| 208 |
function choose(create) { |
| 209 |
const q = pinput.value.trim(); |
| 210 |
const item = create ? { kind: "create", title: q } : pitems[psel]; |
| 211 |
if (!item || !item.title) return; |
| 212 |
closePalette(); |
| 213 |
if (item.kind === "open") go(`/n/${item.id}`); |
| 214 |
else createNote(item.title); |
| 215 |
} |
| 216 |
pinput?.addEventListener("input", () => { |
| 217 |
psel = 0; |
| 218 |
drawPalette(); |
| 219 |
}); |
| 220 |
pinput?.addEventListener("keydown", (e) => { |
| 221 |
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { |
| 222 |
e.preventDefault(); |
| 223 |
psel = (psel + 1) % Math.max(1, pitems.length); |
| 224 |
drawPalette(); |
| 225 |
} else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { |
| 226 |
e.preventDefault(); |
| 227 |
psel = (psel - 1 + pitems.length) % Math.max(1, pitems.length); |
| 228 |
drawPalette(); |
| 229 |
} else if (e.key === "Enter") { |
| 230 |
e.preventDefault(); |
| 231 |
choose(e.shiftKey); |
| 232 |
} else if (e.key === "Escape") { |
| 233 |
e.preventDefault(); |
| 234 |
closePalette(); |
| 235 |
} |
| 236 |
}); |
| 237 |
palette?.addEventListener("mousedown", (e) => { |
| 238 |
if (e.target === palette) closePalette(); |
| 239 |
}); |
| 240 |
$(".find .hint")?.addEventListener("click", openPalette); |
| 241 |
|
| 242 |
|
| 243 |
document.addEventListener("keydown", (e) => { |
| 244 |
if (mod(e) && !e.shiftKey && (e.key === "k" || e.key === "o")) { |
| 245 |
e.preventDefault(); |
| 246 |
palette?.hidden === false ? closePalette() : openPalette(); |
| 247 |
} else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "d") { |
| 248 |
e.preventDefault(); |
| 249 |
$("form[data-daily]")?.requestSubmit(); |
| 250 |
} else if (mod(e) && !e.shiftKey && e.key === "g") { |
| 251 |
e.preventDefault(); |
| 252 |
go(`${base}/graph`); |
| 253 |
} else if (e.altKey && e.code === "KeyN") { |
| 254 |
e.preventDefault(); |
| 255 |
createNote(); |
| 256 |
} else if (e.key === "Escape" && shell?.dataset.open) panel(shell.dataset.open); |
| 257 |
}); |
| 258 |
|
| 259 |
|
| 260 |
const editorEl = $("#editor"); |
| 261 |
let dirty = false; |
| 262 |
let flush = async () => {}; |
| 263 |
|
| 264 |
|
| 265 |
async function go(href) { |
| 266 |
if (dirty) await flush(); |
| 267 |
location.href = href; |
| 268 |
} |
| 269 |
document.addEventListener("click", (e) => { |
| 270 |
const a = e.target.closest?.("a[href]"); |
| 271 |
if (!a || !dirty || mod(e) || a.target === "_blank" || a.classList.contains("unresolved")) return; |
| 272 |
const url = new URL(a.href, location.href); |
| 273 |
if (url.origin !== location.origin || (url.pathname === location.pathname && url.hash)) return; |
| 274 |
e.preventDefault(); |
| 275 |
go(a.href); |
| 276 |
}); |
| 277 |
|
| 278 |
if (editorEl) editor(editorEl); |
| 279 |
|
| 280 |
function editor(form) { |
| 281 |
const id = form.dataset.id; |
| 282 |
const source = $("#source", form); |
| 283 |
const titleEl = $("#title", form); |
| 284 |
const preview = $("#preview", form); |
| 285 |
const panes = $("#panes", form); |
| 286 |
const status = $("#status"); |
| 287 |
let saved = { title: titleEl.value, body: source.value, updated: form.dataset.updated }; |
| 288 |
let timer; |
| 289 |
let saving = null; |
| 290 |
let frame = 0; |
| 291 |
|
| 292 |
let held = false; |
| 293 |
|
| 294 |
|
| 295 |
|
| 296 |
const empty = source.value.trim() === ""; |
| 297 |
let writing = store.get("mode-writing") ?? "split"; |
| 298 |
function mode(m, remember = true) { |
| 299 |
if (m !== "read") writing = m; |
| 300 |
panes.dataset.mode = m; |
| 301 |
for (const b of $$(".segmented [data-mode]")) b.setAttribute("aria-pressed", String(b.dataset.mode === m || (m === "split" && narrow() && b.dataset.mode === "edit"))); |
| 302 |
if (remember) { |
| 303 |
store.set("mode", m); |
| 304 |
if (m !== "read") store.set("mode-writing", m); |
| 305 |
} |
| 306 |
if (m !== "read") draw(); |
| 307 |
} |
| 308 |
mode(empty ? (writing === "read" ? "edit" : writing) : (store.get("mode") ?? "split"), false); |
| 309 |
for (const b of $$(".segmented [data-mode]")) b.addEventListener("click", () => mode(b.dataset.mode)); |
| 310 |
preview.addEventListener("dblclick", (e) => { |
| 311 |
if (panes.dataset.mode !== "read" || e.target.closest("a, button")) return; |
| 312 |
mode(writing); |
| 313 |
source.focus(); |
| 314 |
}); |
| 315 |
document.addEventListener("keydown", (e) => { |
| 316 |
if (mod(e) && !e.shiftKey && e.key === "e") { |
| 317 |
e.preventDefault(); |
| 318 |
if (panes.dataset.mode === "read") { |
| 319 |
mode(writing); |
| 320 |
source.focus(); |
| 321 |
} else mode("read"); |
| 322 |
} |
| 323 |
if (mod(e) && e.key === "s") { |
| 324 |
e.preventDefault(); |
| 325 |
save(); |
| 326 |
} |
| 327 |
}); |
| 328 |
|
| 329 |
|
| 330 |
function draw() { |
| 331 |
cancelAnimationFrame(frame); |
| 332 |
frame = requestAnimationFrame(() => { |
| 333 |
preview.innerHTML = render(source.value, { resolve: resolver(), base }) || '<p class="muted">Nothing here yet.</p>'; |
| 334 |
}); |
| 335 |
} |
| 336 |
|
| 337 |
|
| 338 |
function say(text, kind = "") { |
| 339 |
if (!status) return; |
| 340 |
status.textContent = text; |
| 341 |
status.dataset.kind = kind; |
| 342 |
} |
| 343 |
function changed() { |
| 344 |
dirty = titleEl.value !== saved.title || source.value !== saved.body; |
| 345 |
if (held) return; |
| 346 |
say(dirty ? "Editing…" : "Saved"); |
| 347 |
clearTimeout(timer); |
| 348 |
if (dirty) timer = setTimeout(save, 600); |
| 349 |
} |
| 350 |
async function save() { |
| 351 |
clearTimeout(timer); |
| 352 |
if (held) return; |
| 353 |
if (saving) await saving; |
| 354 |
const body = {}; |
| 355 |
const t = titleEl.value.trim(); |
| 356 |
if (t !== saved.title && t !== "") body.title = t; |
| 357 |
if (source.value !== saved.body) body.body = source.value; |
| 358 |
if (Object.keys(body).length === 0) { |
| 359 |
dirty = titleEl.value.trim() !== saved.title && titleEl.value.trim() !== ""; |
| 360 |
return; |
| 361 |
} |
| 362 |
say("Saving…"); |
| 363 |
body.base = saved.updated; |
| 364 |
saving = (async () => { |
| 365 |
try { |
| 366 |
const r = await fetch(`/api/notes/${id}`, { |
| 367 |
method: "PATCH", |
| 368 |
headers: { "content-type": "application/json" }, |
| 369 |
body: JSON.stringify(body), |
| 370 |
}); |
| 371 |
const answer = await r.json().catch(() => ({})); |
| 372 |
if (r.ok) { |
| 373 |
saved = { title: answer.title, body: answer.body, updated: answer.updated_at }; |
| 374 |
dirty = titleEl.value.trim() !== saved.title || source.value !== saved.body; |
| 375 |
if (body.title !== undefined) renamed(answer); |
| 376 |
say(dirty ? "Editing…" : answer.relinked ? `Saved · updated links in ${answer.relinked} ${answer.relinked === 1 ? "note" : "notes"}` : "Saved"); |
| 377 |
if (dirty) timer = setTimeout(save, 600); |
| 378 |
} else if (r.status === 409) { |
| 379 |
collide(answer.message ?? "Someone else saved this note while you were writing."); |
| 380 |
} else if (r.status === 422 && body.title !== undefined) { |
| 381 |
say(answer.message ?? "That title can't be used.", "error"); |
| 382 |
|
| 383 |
if (body.body !== undefined) { |
| 384 |
titleEl.value = saved.title; |
| 385 |
saving = null; |
| 386 |
return save(); |
| 387 |
} |
| 388 |
} else { |
| 389 |
say(answer.message ?? `Not saved (${r.status}) — retrying`, "error"); |
| 390 |
timer = setTimeout(save, 3000); |
| 391 |
} |
| 392 |
} catch { |
| 393 |
say("Offline — will retry", "error"); |
| 394 |
timer = setTimeout(save, 3000); |
| 395 |
} finally { |
| 396 |
saving = null; |
| 397 |
} |
| 398 |
})(); |
| 399 |
return saving; |
| 400 |
} |
| 401 |
flush = save; |
| 402 |
|
| 403 |
|
| 404 |
|
| 405 |
function collide(message) { |
| 406 |
held = true; |
| 407 |
say(message, "error"); |
| 408 |
if ($("#collide")) return; |
| 409 |
const bar = document.createElement("span"); |
| 410 |
bar.id = "collide"; |
| 411 |
bar.className = "row"; |
| 412 |
const button = (label, act) => { |
| 413 |
const b = document.createElement("button"); |
| 414 |
b.type = "button"; |
| 415 |
b.className = "button button-quiet"; |
| 416 |
b.textContent = label; |
| 417 |
b.addEventListener("click", act); |
| 418 |
bar.append(b); |
| 419 |
}; |
| 420 |
const latest = async () => { |
| 421 |
const r = await fetch(`/api/notes/${id}`); |
| 422 |
return r.ok ? r.json() : null; |
| 423 |
}; |
| 424 |
button("Load theirs", async () => { |
| 425 |
const note = await latest(); |
| 426 |
if (!note) return; |
| 427 |
bar.remove(); |
| 428 |
held = false; |
| 429 |
saved = { title: note.title, body: note.body, updated: note.updated_at }; |
| 430 |
source.value = note.body; |
| 431 |
titleEl.value = note.title; |
| 432 |
renamed(note); |
| 433 |
dirty = false; |
| 434 |
draw(); |
| 435 |
say("Loaded their version"); |
| 436 |
}); |
| 437 |
button("Keep mine", async () => { |
| 438 |
const note = await latest(); |
| 439 |
if (!note) return; |
| 440 |
bar.remove(); |
| 441 |
held = false; |
| 442 |
saved = { ...saved, updated: note.updated_at }; |
| 443 |
save(); |
| 444 |
}); |
| 445 |
status?.after(bar); |
| 446 |
} |
| 447 |
function renamed(note) { |
| 448 |
const here = $(".crumb-here"); |
| 449 |
if (here) here.textContent = note.title; |
| 450 |
document.title = document.title.replace(/^.*? · /, `${note.title} · `); |
| 451 |
} |
| 452 |
titleEl.addEventListener("input", changed); |
| 453 |
titleEl.addEventListener("keydown", (e) => { |
| 454 |
if (e.key === "Enter" || e.key === "ArrowDown") { |
| 455 |
e.preventDefault(); |
| 456 |
source.focus(); |
| 457 |
source.setSelectionRange(0, 0); |
| 458 |
} |
| 459 |
}); |
| 460 |
titleEl.addEventListener("blur", () => { |
| 461 |
if (titleEl.value.trim() === "") titleEl.value = saved.title; |
| 462 |
}); |
| 463 |
form.addEventListener("submit", (e) => { |
| 464 |
e.preventDefault(); |
| 465 |
save(); |
| 466 |
}); |
| 467 |
addEventListener("beforeunload", (e) => { |
| 468 |
if (!dirty) return; |
| 469 |
if (held) return e.preventDefault(); |
| 470 |
|
| 471 |
const body = JSON.stringify({ body: source.value, base: saved.updated, ...(titleEl.value.trim() !== saved.title && titleEl.value.trim() ? { title: titleEl.value.trim() } : {}) }); |
| 472 |
if (body.length < 60_000) |
| 473 |
fetch(`/api/notes/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body, keepalive: true }); |
| 474 |
else e.preventDefault(); |
| 475 |
}); |
| 476 |
|
| 477 |
|
| 478 |
|
| 479 |
document.addEventListener("live:note.changed", async (e) => { |
| 480 |
if (String(e.detail.id) !== id || e.detail.updated_at === saved.updated || saving) return; |
| 481 |
|
| 482 |
if (dirty) return collide("Someone else just saved this note."); |
| 483 |
const r = await fetch(`/api/notes/${id}`); |
| 484 |
if (!r.ok) return; |
| 485 |
const note = await r.json(); |
| 486 |
if (dirty || note.updated_at === saved.updated) return; |
| 487 |
const at = source.selectionStart; |
| 488 |
saved = { title: note.title, body: note.body, updated: note.updated_at }; |
| 489 |
source.value = note.body; |
| 490 |
titleEl.value = note.title; |
| 491 |
renamed(note); |
| 492 |
source.setSelectionRange(at, at); |
| 493 |
draw(); |
| 494 |
say("Updated elsewhere"); |
| 495 |
}); |
| 496 |
document.addEventListener("live:note.removed", (e) => { |
| 497 |
if (String(e.detail.id) === id) { |
| 498 |
dirty = false; |
| 499 |
location.href = base || "/"; |
| 500 |
} |
| 501 |
}); |
| 502 |
document.addEventListener("live:swapped", () => draw()); |
| 503 |
|
| 504 |
|
| 505 |
source.addEventListener("input", (e) => { |
| 506 |
if (e.inputType === "insertText" && e.data === "[") pairBrackets(); |
| 507 |
changed(); |
| 508 |
draw(); |
| 509 |
suggest(); |
| 510 |
}); |
| 511 |
source.addEventListener("keydown", keys); |
| 512 |
source.addEventListener("blur", () => setTimeout(hideSuggest, 120)); |
| 513 |
source.addEventListener("click", hideSuggest); |
| 514 |
|
| 515 |
|
| 516 |
source.addEventListener("scroll", () => { |
| 517 |
if (panes.dataset.mode !== "split" || narrow()) return; |
| 518 |
const ratio = source.scrollTop / Math.max(1, source.scrollHeight - source.clientHeight); |
| 519 |
preview.scrollTop = ratio * (preview.scrollHeight - preview.clientHeight); |
| 520 |
}); |
| 521 |
|
| 522 |
|
| 523 |
preview.addEventListener("click", (e) => { |
| 524 |
const box = e.target.closest("button.check"); |
| 525 |
if (!box) return; |
| 526 |
const n = $$("button.check", preview).indexOf(box); |
| 527 |
const lines = source.value.split("\n"); |
| 528 |
let seen = -1; |
| 529 |
let fence = false; |
| 530 |
for (let i = 0; i < lines.length; i++) { |
| 531 |
if (/^(```|~~~)/.test(lines[i])) fence = !fence; |
| 532 |
if (fence) continue; |
| 533 |
const m = /^((?:\s*>)*\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\]\s)/.exec(lines[i]); |
| 534 |
if (m && ++seen === n) { |
| 535 |
lines[i] = m[1] + (m[2] === " " ? "x" : " ") + m[3] + lines[i].slice(m[0].length); |
| 536 |
source.value = lines.join("\n"); |
| 537 |
changed(); |
| 538 |
draw(); |
| 539 |
return; |
| 540 |
} |
| 541 |
} |
| 542 |
}); |
| 543 |
|
| 544 |
|
| 545 |
function type(text, start = source.selectionStart, end = source.selectionEnd) { |
| 546 |
source.setSelectionRange(start, end); |
| 547 |
if (!document.execCommand?.("insertText", false, text)) { |
| 548 |
source.setRangeText(text, start, end, "end"); |
| 549 |
source.dispatchEvent(new Event("input")); |
| 550 |
} |
| 551 |
} |
| 552 |
|
| 553 |
function pairBrackets() { |
| 554 |
const at = source.selectionStart; |
| 555 |
const v = source.value; |
| 556 |
if (v.slice(at - 2, at) === "[[" && v.slice(at, at + 2) !== "]]") { |
| 557 |
source.setRangeText("]]", at, at, "start"); |
| 558 |
} |
| 559 |
} |
| 560 |
|
| 561 |
function wrap(mark) { |
| 562 |
const { selectionStart: a, selectionEnd: b, value } = source; |
| 563 |
const inner = value.slice(a, b); |
| 564 |
type(`${mark}${inner}${mark}`, a, b); |
| 565 |
source.setSelectionRange(a + mark.length, b + mark.length); |
| 566 |
} |
| 567 |
|
| 568 |
function keys(e) { |
| 569 |
if (suggestKeys(e)) return; |
| 570 |
const { selectionStart: a, selectionEnd: b, value } = source; |
| 571 |
const lineStart = value.lastIndexOf("\n", a - 1) + 1; |
| 572 |
const line = value.slice(lineStart, value.indexOf("\n", a) < 0 ? value.length : value.indexOf("\n", a)); |
| 573 |
if (mod(e) && e.key === "b") { |
| 574 |
e.preventDefault(); |
| 575 |
wrap("**"); |
| 576 |
} else if (mod(e) && e.key === "i") { |
| 577 |
e.preventDefault(); |
| 578 |
wrap("*"); |
| 579 |
} else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "h") { |
| 580 |
e.preventDefault(); |
| 581 |
wrap("=="); |
| 582 |
} else if (mod(e) && e.key === "Enter") { |
| 583 |
|
| 584 |
e.preventDefault(); |
| 585 |
const m = /^(\s*)(?:([-*+]|\d+[.)])\s+)?(?:\[([ xX])\]\s+)?/.exec(line); |
| 586 |
const rest = line.slice(m[0].length); |
| 587 |
const next = m[3] === undefined ? `${m[1]}- [ ] ${rest}` : m[3] === " " ? `${m[1]}${m[2] ?? "-"} [x] ${rest}` : `${m[1]}${m[2] ?? "-"} ${rest}`; |
| 588 |
type(next, lineStart, lineStart + line.length); |
| 589 |
} else if (e.key === "Enter" && !e.shiftKey && a === b) { |
| 590 |
const m = /^(\s*(?:>\s*)*)([-*+]|(\d+)([.)]))\s+(\[[ xX]\]\s+)?(.*)$/.exec(line); |
| 591 |
if (!m || a - lineStart < m[0].length - m[6].length) return; |
| 592 |
e.preventDefault(); |
| 593 |
if (m[6].trim() === "" && a === lineStart + line.length) { |
| 594 |
type("", lineStart, lineStart + line.length); |
| 595 |
return; |
| 596 |
} |
| 597 |
const marker = m[3] !== undefined ? `${Number(m[3]) + 1}${m[4]}` : m[2]; |
| 598 |
type(`\n${m[1]}${marker} ${m[5] ? "[ ] " : ""}`); |
| 599 |
} else if (e.key === "Tab" && !mod(e)) { |
| 600 |
e.preventDefault(); |
| 601 |
const listy = /^\s*([-*+]|\d+[.)])\s/.test(line); |
| 602 |
if (e.shiftKey) { |
| 603 |
const cut = line.match(/^( {1,2}|\t)/)?.[0].length ?? 0; |
| 604 |
if (cut) { |
| 605 |
type(line.slice(cut), lineStart, lineStart + line.length); |
| 606 |
source.setSelectionRange(Math.max(lineStart, a - cut), Math.max(lineStart, b - cut)); |
| 607 |
} |
| 608 |
} else if (listy) { |
| 609 |
type(` ${line}`, lineStart, lineStart + line.length); |
| 610 |
source.setSelectionRange(a + 2, b + 2); |
| 611 |
} else type(" "); |
| 612 |
} else if (e.key === "Escape") source.blur(); |
| 613 |
} |
| 614 |
|
| 615 |
|
| 616 |
const box = $("#suggest"); |
| 617 |
let items = []; |
| 618 |
let sel = 0; |
| 619 |
function query() { |
| 620 |
const at = source.selectionStart; |
| 621 |
const m = /\[\[([^\[\]\n|#]*)$/.exec(source.value.slice(Math.max(0, at - 200), at)); |
| 622 |
return m ? { q: m[1], from: at - m[1].length } : null; |
| 623 |
} |
| 624 |
function suggest() { |
| 625 |
const found = query(); |
| 626 |
if (!found) return hideSuggest(); |
| 627 |
const q = found.q.toLowerCase(); |
| 628 |
const mine = saved.title.toLowerCase(); |
| 629 |
items = vault() |
| 630 |
.filter((n) => n.title.toLowerCase() !== mine) |
| 631 |
.map((n) => ({ title: n.title, s: score(n.title, q) })) |
| 632 |
.filter((x) => x.s !== null) |
| 633 |
.sort((x, y) => y.s - x.s) |
| 634 |
.slice(0, 8) |
| 635 |
.map((x) => x.title); |
| 636 |
if (found.q.trim() !== "" && !items.some((t) => t.toLowerCase() === q)) items.push(found.q.trim()); |
| 637 |
if (items.length === 0) return hideSuggest(); |
| 638 |
sel = Math.min(sel, items.length - 1); |
| 639 |
box.innerHTML = ""; |
| 640 |
const known = new Set(vault().map((n) => n.title.toLowerCase())); |
| 641 |
items.forEach((t, i) => { |
| 642 |
const li = document.createElement("li"); |
| 643 |
li.setAttribute("role", "option"); |
| 644 |
li.className = "suggest-item"; |
| 645 |
li.setAttribute("aria-selected", String(i === sel)); |
| 646 |
li.textContent = known.has(t.toLowerCase()) ? t : `${t} — new`; |
| 647 |
li.addEventListener("mousedown", (ev) => { |
| 648 |
ev.preventDefault(); |
| 649 |
sel = i; |
| 650 |
accept(); |
| 651 |
}); |
| 652 |
box.append(li); |
| 653 |
}); |
| 654 |
const { x, y } = caret(); |
| 655 |
box.hidden = false; |
| 656 |
const w = box.offsetWidth; |
| 657 |
box.style.left = `${Math.max(8, Math.min(x, innerWidth - w - 8))}px`; |
| 658 |
box.style.top = `${y + 4}px`; |
| 659 |
} |
| 660 |
function hideSuggest() { |
| 661 |
if (box) box.hidden = true; |
| 662 |
sel = 0; |
| 663 |
} |
| 664 |
function accept() { |
| 665 |
const found = query(); |
| 666 |
const title = items[sel]; |
| 667 |
if (!found || title === undefined) return; |
| 668 |
const at = source.selectionStart; |
| 669 |
const closes = source.value.slice(at, at + 2) === "]]"; |
| 670 |
type(`${title}${closes ? "" : "]]"}`, found.from, at); |
| 671 |
const end = found.from + title.length + 2; |
| 672 |
source.setSelectionRange(end, end); |
| 673 |
hideSuggest(); |
| 674 |
changed(); |
| 675 |
draw(); |
| 676 |
} |
| 677 |
function suggestKeys(e) { |
| 678 |
if (!box || box.hidden) return false; |
| 679 |
if (e.key === "ArrowDown" || e.key === "ArrowUp") { |
| 680 |
e.preventDefault(); |
| 681 |
sel = (sel + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length; |
| 682 |
suggest(); |
| 683 |
return true; |
| 684 |
} |
| 685 |
if (e.key === "Enter" || e.key === "Tab") { |
| 686 |
e.preventDefault(); |
| 687 |
accept(); |
| 688 |
return true; |
| 689 |
} |
| 690 |
if (e.key === "Escape") { |
| 691 |
e.preventDefault(); |
| 692 |
hideSuggest(); |
| 693 |
return true; |
| 694 |
} |
| 695 |
return false; |
| 696 |
} |
| 697 |
|
| 698 |
function caret() { |
| 699 |
const style = getComputedStyle(source); |
| 700 |
const mirror = document.createElement("div"); |
| 701 |
for (const p of ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderLeftWidth", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "tabSize", "wordSpacing"]) |
| 702 |
mirror.style[p] = style[p]; |
| 703 |
Object.assign(mirror.style, { position: "absolute", visibility: "hidden", whiteSpace: "pre-wrap", overflowWrap: "break-word", top: "0", left: "-9999px" }); |
| 704 |
mirror.textContent = source.value.slice(0, source.selectionStart); |
| 705 |
const mark = document.createElement("span"); |
| 706 |
mark.textContent = ""; |
| 707 |
mirror.append(mark); |
| 708 |
document.body.append(mirror); |
| 709 |
const r = source.getBoundingClientRect(); |
| 710 |
const x = r.left + mark.offsetLeft - source.scrollLeft; |
| 711 |
const y = r.top + mark.offsetTop - source.scrollTop + parseFloat(style.lineHeight || "24"); |
| 712 |
mirror.remove(); |
| 713 |
return { x, y }; |
| 714 |
} |
| 715 |
|
| 716 |
if (empty && document.activeElement === document.body) { |
| 717 |
if (/^Untitled( \d+)?$/.test(titleEl.value)) { |
| 718 |
titleEl.focus(); |
| 719 |
titleEl.select(); |
| 720 |
} else source.focus(); |
| 721 |
} |
| 722 |
} |