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

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

Keyboard shortcuts