benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 9965609c46dc660e7b5b52f91658bb8a67c37294
722 lines · 26.2 KB · ~7.7k tok Raw History
1
// The vault, made an editor. The page arrives whole from the server; this
2
// adds what only a browser can: saving as you type, the reading view kept
3
// in step with the words, [[link]] completion, the quick switcher (⌘K),
4
// the keys, and the panels on a phone. Without it every page still reads
5
// and the editor still saves with its button.
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
/** Where this vault's pages are: "" for yours, /s/<id> for a shared folder. */
29
const base = $("#vault")?.dataset.base ?? "";
30
const space = $("#vault")?.dataset.space;
31
/** In a shared folder, new notes go in the folder of the note you are in. */
32
const folder = () => $('form[action$="/new"] input[name="folder"]')?.value ?? "";
33
34
/** The vault's titles, as the page last heard them. */
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
// ── Theme ─────────────────────────────────────────────────────────────
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
// ── Panels, on a narrow screen ────────────────────────────────────────
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
// ── Small things every page does ─────────────────────────────────────
93
// A join link selects itself, and is copied, when you click it.
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
// The sidebar's box filters the list as you type; Enter searches the words.
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
// Links to notes not written yet make the note, then open it.
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
// ── The quick switcher ────────────────────────────────────────────────
133
const palette = $("#palette");
134
const pinput = $("#palette-input");
135
const plist = $("#palette-list");
136
let pitems = [];
137
let psel = 0;
138
139
/** A fuzzy score: every letter in order, more for runs and word starts. Null when it does not match. */
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
// ── Keys, everywhere ──────────────────────────────────────────────────
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
// ── The editor ────────────────────────────────────────────────────────
260
const editorEl = $("#editor");
261
let dirty = false;
262
let flush = async () => {};
263
264
/** Leave the page — once whatever is unsaved is saved. */
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
  // Someone else saved while you were writing: nothing saves until you choose.
292
  let held = false;
293
294
  // Views: edit, split (side by side, on a wide screen), read. ⌘E flips
295
  // between reading and the last way you were writing.
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
  // The reading view follows the words, a frame at a time.
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
  // Saving: a moment after you stop, and before you leave.
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
          // Keep the words: save them under the old title.
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
  // Two people, one note: theirs is saved, yours is on screen. Say so, and
404
  // let the writer choose — never write over either without asking.
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
    // Best effort on the way out; the prompt covers what keepalive cannot carry.
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
  // Someone (another tab, a script, Claude over MCP) changed this note:
478
  // take their words if you have none of your own in flight.
479
  document.addEventListener("live:note.changed", async (e) => {
480
    if (String(e.detail.id) !== id || e.detail.updated_at === saved.updated || saving) return;
481
    // Mid-sentence when theirs arrives: don't wait for the save to find out.
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
  // Typing: the words, the view, the save, and [[ completion.
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
  // The split view scrolls together.
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
  // A task's box, ticked in the reading view, ticks it in the words.
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
  /** Put text in as if typed, so ⌘Z undoes it. */
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
      // ⌘↵ cycles a line: plain → task → done → plain.
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); // An empty item ends the list.
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
  // [[ completion: the titles that fit what you have typed so far.
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
  /** Where the caret is on screen: a hidden copy of the textarea, measured. */
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts