pindeck 3.0.0-beta.3 → 3.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/editor.js CHANGED
@@ -1,6 +1,11 @@
1
1
  /* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
2
2
 
3
3
  const $ = (id) => document.getElementById(id);
4
+ /* The editor's own folder: / when it runs alone, /d/<deck>/ behind the front (pindeck), which serves several
5
+ decks on one port. Every URL of this deck's server starts here. */
6
+ const BASE = new URL(".", location.href).pathname;
7
+ const FRONT = BASE.startsWith("/d/");
8
+ const SLIDE_KEY = FRONT ? `editor.slide:${BASE}` : "editor.slide"; // decks share the origin, not their slide
4
9
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
5
10
  /* Changes to the deck carry the revision of the files this tab shows (state.rev); the server refuses them
6
11
  (409, code "stale") if the files have changed since. A change that went through gives the new revision. */
@@ -9,7 +14,7 @@ const CHANGES = new Set(["edit", "text", "split", "remove", "notes", "slide-hidd
9
14
  const api = async (p, body) => {
10
15
  const change = body !== undefined && CHANGES.has(p.split("?")[0]);
11
16
  if (change && state.rev) body = { ...body, rev: state.rev };
12
- const res = await fetch("/api/" + p, {
17
+ const res = await fetch(BASE + "api/" + p, {
13
18
  method: body === undefined ? "GET" : "POST",
14
19
  headers: { "content-type": "application/json" },
15
20
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -18,7 +23,7 @@ const api = async (p, body) => {
18
23
  return res;
19
24
  };
20
25
 
21
- const savedSlide = (() => { try { return +localStorage.getItem("editor.slide") || 0; } catch { return 0; } })();
26
+ const savedSlide = (() => { try { return +localStorage.getItem(SLIDE_KEY) || 0; } catch { return 0; } })();
22
27
  const state = {
23
28
  status: {}, slides: [], current: savedSlide, pins: [], mode: "select", sel: null, // mode: "select" (move, resize) or "pin"
24
29
  busy: false, status: {}, turns: new Map(), // turnId -> { undone }
@@ -31,8 +36,8 @@ let measure = null; // { iframe, bridge }
31
36
  let measureReady = null, measureResolve = null;
32
37
 
33
38
  // showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
34
- // /deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
35
- function deckSrc(mode, extra = "") { return `/deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
39
+ // deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
40
+ function deckSrc(mode, extra = "") { return `${BASE}deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
36
41
 
37
42
  function loadMain() {
38
43
  const f = document.createElement("iframe");
@@ -42,10 +47,22 @@ function loadMain() {
42
47
  $("stage").appendChild(f);
43
48
  if (pendingMain) pendingMain.remove();
44
49
  pendingMain = f;
50
+ // a deck page whose slides never start (a script that fails, broken HTML, the file gone): say so
51
+ setTimeout(() => { if (pendingMain === f) stageProblem("The deck page did not start within 15 seconds."); }, 15000);
45
52
  }
53
+ /* Why the stage stays empty. Messages still go to the assistant, which can be asked to fix the deck. */
54
+ function stageProblem(why) {
55
+ let el = $("stage-problem");
56
+ if (!why) return el?.remove();
57
+ if (!el) { el = document.createElement("div"); el.id = "stage-problem"; el.className = "stage-problem"; $("stage").appendChild(el); }
58
+ el.innerHTML = `<b>PinDeck cannot show this deck.</b><span>${esc(why)}</span>` +
59
+ `<span>Fix the file, or describe the problem in the chat and the assistant can fix it.</span>`;
60
+ }
61
+ let measureReject = null;
46
62
  function loadMeasure() {
47
63
  measure?.iframe.remove();
48
- measureReady = new Promise((r) => (measureResolve = r));
64
+ measureReady = new Promise((resolve, reject) => { measureResolve = resolve; measureReject = reject; });
65
+ measureReady.catch(() => {});
49
66
  const f = document.createElement("iframe");
50
67
  f.className = "measure";
51
68
  f.setAttribute("aria-hidden", "true");
@@ -55,19 +72,52 @@ function loadMeasure() {
55
72
  $("stage").appendChild(f);
56
73
  }
57
74
 
58
- window.__editorHost = {
59
- bridgeReady(role, bridge, win) {
75
+ /* The slide frames run the deck sandboxed, in an origin of their own (see VIEW in server.mjs): the editor and
76
+ the bridge in each frame talk by messages. A frame's bridge is a proxy whose calls answer with promises;
77
+ the bridge calls the methods of editorHost below. Only the editor's own frames are listened to, and a deck's
78
+ own script, which shares its frame with the bridge, can do no more than the bridge: hand edits to its deck. */
79
+ let callSeq = 0;
80
+ const answers = new Map(); // id -> resolve
81
+ function bridgeOf(win) {
82
+ // not a thenable: a promise resolved with the proxy must not call it
83
+ return new Proxy({}, { get: (_, method) => method === "then" ? undefined : (...args) => new Promise((resolve) => {
84
+ const id = ++callSeq;
85
+ answers.set(id, resolve);
86
+ win.postMessage({ pindeck: "bridge", id, method, args }, "*");
87
+ }) });
88
+ }
89
+ addEventListener("message", async (e) => {
90
+ const m = e.data;
91
+ if (!m || typeof m !== "object" || !e.source) return;
92
+ const from = [main?.iframe, pendingMain, measure?.iframe].find((f) => f?.contentWindow === e.source);
93
+ if (!from) return;
94
+ if (m.pindeck === "bridge-answer") { answers.get(m.id)?.(m.value); answers.delete(m.id); return; }
95
+ if (m.pindeck !== "host" || !Object.hasOwn(editorHost, m.method)) return;
96
+ // only a frame that is showing may edit; a frame still loading may only say that it is ready, or why not
97
+ if (m.method !== "bridgeReady" && m.method !== "deckFailed" && from !== main?.iframe) return;
98
+ let value = null;
99
+ try { value = await editorHost[m.method](...(Array.isArray(m.args) ? m.args : []), e.source); } catch (err) { console.error(err); }
100
+ e.source.postMessage({ pindeck: "host-answer", id: m.id, value: value ?? null }, "*");
101
+ });
102
+
103
+ const editorHost = {
104
+ deckFailed(role, why, win) {
105
+ if (role === "measure") { if (measure?.iframe.contentWindow === win) measureReject?.(new Error(why)); return; }
106
+ if (pendingMain?.contentWindow === win || main?.iframe.contentWindow === win) stageProblem(why);
107
+ },
108
+ bridgeReady(role, inf, win) {
60
109
  if (role === "measure") {
61
- if (measure && measure.iframe.contentWindow === win) { measure.bridge = bridge; measureResolve(bridge); }
110
+ if (measure && measure.iframe.contentWindow === win) { measure.bridge = bridgeOf(win); measureResolve(measure.bridge); }
62
111
  return;
63
112
  }
64
113
  if (!pendingMain || pendingMain.contentWindow !== win) return;
65
114
  const old = main;
115
+ const bridge = bridgeOf(win);
66
116
  main = { iframe: pendingMain, bridge };
67
117
  pendingMain = null;
68
118
  main.iframe.classList.remove("loading");
69
119
  old?.iframe.remove();
70
- const inf = bridge.info();
120
+ stageProblem(null);
71
121
  state.slides = inf.slides;
72
122
  state.current = Math.min(state.current, state.slides.length - 1);
73
123
  if (inf.current !== state.current) bridge.goto(state.current);
@@ -83,7 +133,7 @@ window.__editorHost = {
83
133
  },
84
134
  slideChanged(index) {
85
135
  state.current = index;
86
- try { localStorage.setItem("editor.slide", String(index)); } catch {}
136
+ try { localStorage.setItem(SLIDE_KEY, String(index)); } catch {}
87
137
  renderSlides();
88
138
  renderToolbar();
89
139
  loadNotes();
@@ -120,13 +170,20 @@ window.__editorHost = {
120
170
  loadMain(); // re-render: math, footer numbers, and the file as saved
121
171
  }, "Could not save the text.");
122
172
  },
123
- commitSplit(op, done) {
124
- queueEdit(async () => {
125
- const res = await api("split", op);
173
+ commitSplit(op) { // answers with the new bullet's id as saved (null if it was not)
174
+ let eid = null;
175
+ return queueEdit(async () => {
176
+ const { whole, ...req } = op;
177
+ const res = await api("split", req);
126
178
  const d = await res.json().catch(() => ({}));
127
- if (!res.ok) { toast(d.error || "Could not add the bullet."); loadMain(); return; }
128
- done?.(d.eid);
129
- }, "Could not add the bullet.");
179
+ if (!res.ok) {
180
+ if (d.code === "stale") keepRejectedText(whole ?? op.text, d);
181
+ else toast(d.error || "Could not add the bullet.");
182
+ loadMain();
183
+ return;
184
+ }
185
+ eid = d.eid;
186
+ }, "Could not add the bullet.").then(() => eid);
130
187
  },
131
188
  commitRemove(eid) {
132
189
  queueEdit(async () => {
@@ -136,7 +193,11 @@ window.__editorHost = {
136
193
  }, "Could not remove the bullet.");
137
194
  },
138
195
  refresh() { queueEdit(() => loadMain(), "Could not reload the slide."); },
139
- editingChanged(on) { state.editing = on; },
196
+ editingChanged(on) {
197
+ state.editing = on;
198
+ // after the edit's own save, which the bridge asks for next
199
+ if (!on && heldFiles) setTimeout(() => queueEdit(() => { const d = heldFiles; heldFiles = null; if (d) applyFiles(d); }), 0);
200
+ },
140
201
  requestDelete(eid) { requestDelete(eid); },
141
202
  dropFiles(files, at) { attachFiles(files, at); },
142
203
  dragOver(on) { $("drop-hint").hidden = !on; },
@@ -206,9 +267,11 @@ function toast(msg, { html = false, ms = 3200 } = {}) {
206
267
  toastTimer = setTimeout(() => ($("toast").hidden = true), ms);
207
268
  }
208
269
 
270
+ // a deck page that never starts, or stops answering, must not hold up a message: give up after a while
271
+ const within = (ms, p, what) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} timed out`)), ms))]);
209
272
  async function measureSlide(index) {
210
- const b = await measureReady;
211
- return b.measure(index);
273
+ const b = await within(8000, measureReady, "The measuring frame");
274
+ return within(8000, b.measure(index), "Measuring the slide");
212
275
  }
213
276
  // one measurement at a time: the measure frame has a single "current slide"
214
277
  let measureChain = Promise.resolve();
@@ -297,7 +360,7 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
297
360
  if (!res.ok) return toast(d.error || "Could not move the slide.");
298
361
  if (d.unchanged) return;
299
362
  state.current = d.index; // the reload opens the moved slide
300
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
363
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
301
364
  loadMain();
302
365
  }
303
366
 
@@ -366,7 +429,7 @@ notesBox.addEventListener("keydown", (e) => {
366
429
  // closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
367
430
  addEventListener("pagehide", () => {
368
431
  if (notesDirty() && !state.busy)
369
- navigator.sendBeacon("/api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
432
+ navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
370
433
  });
371
434
  function setNotesOpen(open) {
372
435
  $("notes-panel").classList.toggle("collapsed", !open);
@@ -465,6 +528,31 @@ function wireMenu(btnId, menuId, beforeOpen) {
465
528
  const closeMenus = () => setMenu(null, null, false);
466
529
  document.addEventListener("click", (e) => { if (!e.target.closest(".menu")) closeMenus(); });
467
530
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }, true);
531
+ /* The deck menu, behind the front only: the decks in the deck's folder and the asset roots (and one folder
532
+ down). Each is a link, so a deck opens in place, or in a new tab with ⌘-click; the deck left behind keeps
533
+ its chat and undo, and the assistant goes on with a turn it has begun. */
534
+ if (FRONT) {
535
+ $("deck-btn").disabled = false;
536
+ $("deck-btn").title = "Open another deck";
537
+ $("deck-btn").querySelector(".caret").hidden = false;
538
+ wireMenu("deck-btn", "deck-menu", async () => {
539
+ const menu = $("deck-menu");
540
+ menu.innerHTML = '<div class="menu-note">Looking for decks…</div>';
541
+ let decks = [];
542
+ try { decks = (await (await fetch("/api/decks")).json()).decks; }
543
+ catch { menu.innerHTML = '<div class="menu-note">The deck list is not available.</div>'; return; }
544
+ const roots = [...new Set(decks.map((d) => d.root))];
545
+ menu.innerHTML = roots.map((root) => (roots.length > 1 ? `<div class="group" title="${esc(root)}">${esc(root.split("/").pop())}</div>` : "") +
546
+ decks.filter((d) => d.root === root).map((d) => {
547
+ const here = BASE === `/d/${d.key}/`;
548
+ return d.problem
549
+ ? `<button class="opt" role="menuitem" disabled title="${esc(d.problem)}"><span class="tick"></span><span><b>${esc(d.rel)}</b><span class="why">${esc(d.problem.replace(/^\S+ /, "").split(". ")[0])}.</span></span></button>`
550
+ : `<a class="opt" role="menuitem" href="/d/${esc(d.key)}/" ${here ? 'aria-current="page"' : ""}><span class="tick">${here ? "✓" : ""}</span>` +
551
+ `<b>${esc(d.rel)}</b>${here ? "" : d.open ? '<span class="d">open</span>' : ""}</a>`;
552
+ }).join("")).join("") +
553
+ '<div class="menu-note">A deck opens here (⌘-click: in a new tab), with its own chat and undo. The assistant finishes its turn in the deck you leave.</div>';
554
+ });
555
+ }
468
556
  const setExportMenu = (open) => setMenu($("export-btn"), $("export-menu"), open);
469
557
  wireMenu("export-btn", "export-menu");
470
558
  $("export-menu").addEventListener("click", async (e) => {
@@ -493,7 +581,7 @@ $("export-menu").addEventListener("click", async (e) => {
493
581
  });
494
582
 
495
583
  $("present").onclick = () => {
496
- window.open(`/deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
584
+ window.open(`${BASE}deck/${state.slides[state.current]?.hash || ""}`, "_blank");
497
585
  };
498
586
 
499
587
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -515,7 +603,6 @@ function pinsChanged() {
515
603
  main?.bridge.renderPins(state.pins);
516
604
  renderPinTray();
517
605
  renderSlides();
518
- api("pins", { pins: state.pins });
519
606
  }
520
607
  function shortEid(p) {
521
608
  if (p.tag?.startsWith("footer")) return "footer";
@@ -535,7 +622,7 @@ function renderPinTray() {
535
622
  }
536
623
  $("pin-tray").addEventListener("click", (e) => {
537
624
  const x = e.target.closest("[data-x]");
538
- if (x) return window.__editorHost.removePin(+x.dataset.x);
625
+ if (x) return editorHost.removePin(+x.dataset.x);
539
626
  if (e.target.closest("[data-unsel]")) return main?.bridge.deselect();
540
627
  if (e.target.closest("[data-sel]")) return main?.bridge.highlight(state.sel.eid);
541
628
  const chip = e.target.closest(".chip");
@@ -571,34 +658,41 @@ function md(s) {
571
658
  }).join("");
572
659
  return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
573
660
  }
661
+ /* A screenshot in the chat: a small picture that opens large. Only the newest ones are kept on disk;
662
+ an older one says so (see the error listener below). */
663
+ const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
664
+ `<img src="${BASE}shots/${encodeURIComponent(file)}" alt="" loading="lazy">${label ? ` ${esc(label)}` : ""}</button>`;
665
+ // only the most recent change still in effect can be undone: it alone gets an Undo button
666
+ const lastLive = () => state.status?.undoId;
574
667
  function entryHtml(e) {
575
668
  switch (e.kind) {
576
669
  case "user":
577
670
  return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
578
671
  `<span class="chip sel" title="${esc(e.selection.eid)}"><span class="n">▢</span> S${e.selection.slide} · ${esc(e.selection.label)}</span>` : ""}${(e.pins || []).map((p) =>
579
672
  `<span class="chip" title="${esc(p.eid)}"><span class="n">${p.n}</span> S${p.slide} · ${esc(p.label || p.eid)}</span>`).join("")}${
580
- e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
581
- `<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="/deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
673
+ e.screenshot?.file ? shotChip(e.screenshot.file, `screenshot S${e.screenshot.slide}`) : e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
674
+ `<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="${BASE}deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
582
675
  case "assistant": return md(e.text);
583
676
  case "tool":
584
- return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
677
+ return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.shot ? " " + shotChip(e.shot) : ""}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
585
678
  case "edit": {
586
- const t = state.turns.get(e.turnId);
587
- return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
588
- <button class="small" data-diff="${e.turnId}">Diff</button>
589
- ${t && !t.undone ? `<button class="small" data-undo="${e.turnId}">Undo</button>` : t?.undone ? "<span>undone</span>" : ""}`;
679
+ const t = state.turns.get(e.turnId), undone = t ? t.undone : e.undone;
680
+ return `<span class="files ${undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
681
+ ${t ? `<button class="small" data-diff="${e.turnId}">Diff</button>` : ""}
682
+ ${t && !t.undone && t.id === lastLive() ? `<button class="small" data-undo="${e.turnId}">Undo</button>` : undone ? "<span>undone</span>" : ""}`;
590
683
  }
591
684
  case "turn": {
592
- const t = e.turnId ? state.turns.get(e.turnId) : null;
685
+ // a change from before PinDeck restarted: its undo history (and diff) ended with that run
686
+ const t = e.turnId ? state.turns.get(e.turnId) : null, undone = t ? t.undone : e.undone;
593
687
  const parts = [];
594
688
  if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
595
689
  if (e.files?.length) {
596
- parts.push(`<span class="files ${t?.undone ? "undone" : ""}">Changed ${e.files.map((f) =>
690
+ parts.push(`<span class="files ${undone ? "undone" : ""}" ${t ? "" : 'title="From before PinDeck restarted: no diff or undo any more"'}>Changed ${e.files.map((f) =>
597
691
  `${esc(f.file)} ` + (f.binary ? `<span class="add">(binary)</span>`
598
692
  : `<span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`)).join(", ")}</span>`);
599
- parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
600
- if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
601
- if (t?.undone) parts.push("<span>undone</span>");
693
+ if (t) parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
694
+ if (t && !t.undone && t.id === lastLive()) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
695
+ if (undone) parts.push("<span>undone</span>");
602
696
  } else if (!e.error) parts.push("No file changes");
603
697
  if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
604
698
  if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets the assistant run only its own commands (layout, shot, slides, pdf) and edit ${esc(state.status?.deckFile || "index.html")}, theme.css and the deck's other files, never brand.css">` +
@@ -629,7 +723,23 @@ function clearChat() {
629
723
  $("chat-empty").hidden = false;
630
724
  }
631
725
 
726
+ // a picture that is no longer kept: the chip says so and opens nothing (errors do not bubble: capture)
727
+ chatEl.addEventListener("error", (e) => {
728
+ const chip = e.target.closest?.("[data-shot]");
729
+ if (!chip || e.target.tagName !== "IMG") return;
730
+ chip.removeAttribute("data-shot");
731
+ chip.classList.add("gone");
732
+ chip.title = "Only the newest screenshots are kept";
733
+ chip.textContent = "📷 no longer kept";
734
+ }, true);
632
735
  chatEl.addEventListener("click", async (e) => {
736
+ const s = e.target.closest("[data-shot]");
737
+ if (s) {
738
+ $("shot-img").src = `${BASE}shots/${encodeURIComponent(s.dataset.shot)}`;
739
+ $("shot-title").textContent = s.dataset.shot;
740
+ $("shot-dialog").showModal();
741
+ return;
742
+ }
633
743
  const d = e.target.closest("[data-diff]");
634
744
  if (d) {
635
745
  const res = await api(`turn/${d.dataset.diff}/diff`);
@@ -651,6 +761,9 @@ chatEl.addEventListener("click", async (e) => {
651
761
  }
652
762
  });
653
763
  $("diff-close").onclick = () => $("diff-dialog").close();
764
+ $("shot-close").onclick = () => $("shot-dialog").close();
765
+ // Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
766
+ $("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
654
767
 
655
768
  function setBusy(busy) {
656
769
  state.busy = busy;
@@ -681,7 +794,15 @@ async function send() {
681
794
  ({ path, kind, name, bytes, width, height, duration, sampleRate, channels, waveform, pages, pageSize, droppedAt })),
682
795
  current: s ? { index: state.current, eid: s.eid } : null,
683
796
  });
684
- if (!res.ok) { alert((await res.json()).error || "Could not send."); return; }
797
+ if (!res.ok) {
798
+ const raw = await res.text().catch(() => "");
799
+ let d = null;
800
+ try { d = JSON.parse(raw); } catch {}
801
+ const why = d?.error || (/too large/i.test(raw) ? "The message is too large to send (the limit is 5 MB)." : `Could not send the message (${res.status}).`);
802
+ // a missing backend needs the user's attention; the rest is said in passing, the message stays in the box
803
+ if (d?.code?.endsWith("-unavailable")) alert(why); else toast(why, { ms: 8000 });
804
+ return;
805
+ }
685
806
  $("input").value = "";
686
807
  autosize();
687
808
  state.shot = null;
@@ -694,9 +815,9 @@ async function send() {
694
815
  }
695
816
  }
696
817
  $("send").onclick = send;
697
- $("stop").onclick = () => api("stop", {});
818
+ $("stop").onclick = () => { $("working-text").textContent = "Stopping…"; api("stop", {}); };
698
819
  $("new-chat").onclick = async () => {
699
- if (!confirm("Start a new conversation? The assistant forgets this chat; your slides stay as they are.")) return;
820
+ if (!confirm("Start a new conversation? The assistant forgets this chat; your slides, and Undo and Redo, stay as they are.")) return;
700
821
  await api("new-chat", {});
701
822
  };
702
823
  function autosize() {
@@ -718,7 +839,10 @@ function handleKey(k, mod = {}) {
718
839
  else if (k === "v" || k === "V") setMode("select");
719
840
  else if (k === "h" || k === "H") toggleHidden();
720
841
  else if (k === "Escape") { if (state.mode === "pin") setMode("select"); else main?.bridge.deselect(); }
721
- else if (k === "Enter") { if (!(state.sel && state.mode === "select" && main?.bridge.editSelection())) $("input").focus(); }
842
+ else if (k === "Enter") {
843
+ if (!(state.sel && state.mode === "select" && main)) $("input").focus();
844
+ else main.bridge.editSelection().then((editing) => { if (!editing) $("input").focus(); });
845
+ }
722
846
  else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete(state.sel.eid);
723
847
  else if (k.startsWith("Arrow") && state.sel && state.mode === "select") {
724
848
  const step = mod.shift ? 10 : 1;
@@ -729,6 +853,10 @@ function handleKey(k, mod = {}) {
729
853
  }
730
854
  document.addEventListener("keydown", (e) => {
731
855
  if (e.target.closest?.("input, textarea, dialog")) return;
856
+ // a menu and the button that opens it keep Enter, Space and the arrow keys (a menu opens, an item is chosen):
857
+ // no slide change, no jump to the chat. Other buttons do not: after a click on ‹ › the focus stays there
858
+ // while the slide is clicked (the slide frame keeps it), and Enter must still edit the selected text.
859
+ if (["Enter", " ", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key) && e.target.closest?.(".menu, [aria-haspopup]")) return;
732
860
  const meta = e.metaKey || e.ctrlKey;
733
861
  if (meta && (e.key === "z" || e.key === "Z")) { e.preventDefault(); return handleKey(e.key, { meta, shift: e.shiftKey }); }
734
862
  if (meta || e.altKey) return;
@@ -741,13 +869,16 @@ document.addEventListener("keydown", (e) => {
741
869
  /* ---- Server events ---------------------------------------------------------------------------------- */
742
870
  let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
743
871
  function applyStatus(s) {
872
+ if (!s.models) s.models = state.status?.models; // sent only when they change
873
+ const undoMoved = s.undoId !== state.status?.undoId;
744
874
  state.status = s;
875
+ if (undoMoved) rerenderTurns(); // the Undo button moves to the change that is now the most recent
745
876
  // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
746
877
  if (s.deck) {
747
878
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
748
879
  const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
749
880
  $("deck-name").textContent = name;
750
- $("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
881
+ $("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
751
882
  if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
752
883
  document.title = `${name} · PinDeck`;
753
884
  }
@@ -758,7 +889,7 @@ function applyStatus(s) {
758
889
  renderEffortMenu();
759
890
  $("new-chat").disabled = !!s.busy;
760
891
  $("stop").hidden = !s.busy || !!s.switching;
761
- $("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
892
+ $("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : "The assistant is working…";
762
893
  $("conn").classList.toggle("on", !!s.running);
763
894
  $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
764
895
  // only the selected backend's availability matters: Codex missing is no news to someone using Claude
@@ -791,59 +922,89 @@ function applyStatus(s) {
791
922
  shownProblem = s.deckProblem;
792
923
  renderToolbar();
793
924
  }
925
+ let heldFiles = null; // file changes that came while a text edit was open
926
+ function applyFiles({ files, manual, rev }) {
927
+ if (rev) state.rev = rev;
928
+ const cssOnly = files.every((f) => f.endsWith(".css"));
929
+ if (manual && main) {
930
+ loadMeasure(); // the visible slide already shows the hand edit
931
+ } else if (cssOnly && main) {
932
+ main.bridge.reloadCss(files);
933
+ measure?.bridge?.reloadCss(files);
934
+ } else {
935
+ loadMain();
936
+ loadMeasure();
937
+ }
938
+ }
939
+ /* The deck's events: behind the front through the worker all of this browser's tabs share (browsers open at
940
+ most 6 connections to one port; see public/events-worker.js), on its own server straight from it */
941
+ function openEvents(on, onError) {
942
+ if (FRONT && typeof SharedWorker === "function") {
943
+ const deck = BASE.split("/")[2];
944
+ const w = new SharedWorker("/editor/events-worker.js", { name: "pindeck-events" });
945
+ w.port.onmessage = ({ data: m }) => (m.type === "error" ? onError() : on[m.type]?.(m.data));
946
+ w.port.start();
947
+ w.port.postMessage({ subscribe: deck });
948
+ addEventListener("pagehide", () => w.port.postMessage({ close: true }));
949
+ addEventListener("pageshow", (e) => { if (e.persisted) w.port.postMessage({ subscribe: deck }); });
950
+ return;
951
+ }
952
+ const es = new EventSource(BASE + "api/events");
953
+ for (const [type, fn] of Object.entries(on)) es.addEventListener(type, (ev) => fn(JSON.parse(ev.data)));
954
+ es.onerror = onError;
955
+ }
794
956
  function connect() {
795
- const es = new EventSource("/api/events");
796
- es.addEventListener("hello", (ev) => {
797
- const d = JSON.parse(ev.data);
798
- // reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
799
- if (state.rev && d.rev && d.rev !== state.rev && main) { loadMain(); loadMeasure(); }
800
- if (d.rev) state.rev = d.rev;
801
- clearChat();
802
- state.turns = new Map(d.turns.map((t) => [t.id, t]));
803
- d.chat.forEach(renderEntry);
804
- applyStatus(d.status);
805
- scrollChat();
806
- });
807
- es.addEventListener("chat", (ev) => {
808
- const e = JSON.parse(ev.data);
809
- if ((e.kind === "turn" || e.kind === "edit") && e.turnId) state.turns.set(e.turnId, { id: e.turnId, undone: false });
810
- renderEntry(e);
811
- scrollChat();
812
- });
813
- es.addEventListener("chat-update", (ev) => renderEntry(JSON.parse(ev.data)));
814
- es.addEventListener("import", (ev) => showImportThumbs(JSON.parse(ev.data)));
815
- es.addEventListener("status", (ev) => applyStatus(JSON.parse(ev.data)));
816
- es.addEventListener("reset", () => { clearChat(); state.turns.clear(); });
817
- es.addEventListener("undone", (ev) => {
818
- const d = JSON.parse(ev.data);
819
- const t = state.turns.get(d.turnId);
820
- if (t) t.undone = d.undone !== false;
821
- rerenderTurns();
822
- });
823
- es.addEventListener("files", (ev) => {
824
- const { files, manual, rev } = JSON.parse(ev.data);
825
- if (rev) state.rev = rev;
826
- const cssOnly = files.every((f) => f.endsWith(".css"));
827
- if (manual && main) {
828
- loadMeasure(); // the visible slide already shows the hand edit
829
- } else if (cssOnly && main) {
830
- main.bridge.reloadCss(files);
831
- measure?.bridge?.reloadCss(files);
832
- } else {
833
- loadMain();
834
- loadMeasure();
835
- }
836
- });
837
- es.addEventListener("measure", async (ev) => {
838
- const { id, index } = JSON.parse(ev.data);
839
- try { api("measure-result", { id, result: await queueMeasure(index) }); }
840
- catch (err) { api("measure-result", { id, error: String(err) }); }
841
- });
957
+ openEvents({
958
+ hello(d) {
959
+ // reconnected after missing file events (tunnel dropped, laptop asleep, deck server restarted): show the
960
+ // files as they are now
961
+ if (state.rev && d.rev && d.rev !== state.rev && main) { loadMain(); loadMeasure(); }
962
+ if (d.rev) state.rev = d.rev;
963
+ clearChat();
964
+ state.turns = new Map(d.turns.map((t) => [t.id, t]));
965
+ d.chat.forEach(renderEntry);
966
+ applyStatus(d.status);
967
+ scrollChat();
968
+ },
969
+ chat(e) {
970
+ const change = (e.kind === "turn" || e.kind === "edit") && e.turnId;
971
+ if (change) state.turns.set(e.turnId, { id: e.turnId, undone: false });
972
+ renderEntry(e);
973
+ scrollChat();
974
+ },
975
+ "chat-update": (e) => renderEntry(e),
976
+ import: (d) => showImportThumbs(d),
977
+ status: (s) => applyStatus(s),
978
+ reset() { clearChat(); state.turns.clear(); },
979
+ undone(d) {
980
+ const t = state.turns.get(d.turnId);
981
+ if (t) t.undone = d.undone !== false;
982
+ rerenderTurns();
983
+ },
984
+ files(d) {
985
+ // typing in the slide: the reload waits until the edit ends, and the edit is saved against the files
986
+ // it began with (changed meanwhile: refused, with the text to copy; see keepRejectedText)
987
+ if (state.editing) {
988
+ heldFiles = heldFiles ? { files: [...new Set([...heldFiles.files, ...d.files])], manual: heldFiles.manual && d.manual, rev: d.rev } : d;
989
+ return;
990
+ }
991
+ applyFiles(d);
992
+ },
993
+ async measure({ id, index }) {
994
+ try { api("measure-result", { id, result: await queueMeasure(index) }); }
995
+ catch (err) { api("measure-result", { id, error: String(err) }); }
996
+ },
997
+ // the deck server stopped: the front opens it again and its "hello" brings the state as it is then
998
+ gone(d) {
999
+ $("conn").classList.remove("on");
1000
+ $("conn").title = d?.error || "The deck's server stopped; reconnecting…";
1001
+ if (d?.error) toast(d.error, { ms: 20000 });
1002
+ },
842
1003
  // EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
843
1004
  // which asks for a new login link
844
- es.onerror = async () => {
1005
+ }, async () => {
845
1006
  try { if ((await api("ping")).status === 403) location.reload(); } catch {}
846
- };
1007
+ });
847
1008
  }
848
1009
 
849
1010
  /* ---- New slides --------------------------------------------------------------------------------------
@@ -868,7 +1029,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
868
1029
  if (!res.ok) return toast(d.error || "Could not add the slide.");
869
1030
  state.current = d.index; // the reload opens the new slide ...
870
1031
  state.pendingTitleEdit = item.dataset.slide !== "duplicate"; // ... with its title ready for typing
871
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
1032
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
872
1033
  loadMain();
873
1034
  });
874
1035
 
@@ -881,7 +1042,7 @@ $("import-file").addEventListener("change", async () => {
881
1042
  $("import-file").value = "";
882
1043
  if (!file) return;
883
1044
  toast(`Reading ${file.name}…`);
884
- const res = await fetch("/api/import", { method: "POST", body: file,
1045
+ const res = await fetch(BASE + "api/import", { method: "POST", body: file,
885
1046
  headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
886
1047
  const d = await res.json().catch(() => ({}));
887
1048
  if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
@@ -925,7 +1086,7 @@ function showImportThumbs(ev) {
925
1086
  for (const c of importChecks()) {
926
1087
  const img = new Image();
927
1088
  img.alt = "";
928
- img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}`;
1089
+ img.src = `${BASE}api/import-thumb?id=${importing.id}&n=${c.value}`;
929
1090
  c.closest(".thumb").append(img);
930
1091
  }
931
1092
  }
@@ -943,7 +1104,7 @@ $("import-dialog").addEventListener("close", async () => {
943
1104
  toast(`Imported ${d.count} slide${d.count > 1 ? "s" : ""}.` + (d.missing ? ` ${d.missing} diagram${d.missing > 1 ? "s" : ""} could not be pictured (no PowerPoint or Keynote).` : "") +
944
1105
  (d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
945
1106
  state.current = d.index;
946
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
1107
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
947
1108
  loadMain();
948
1109
  });
949
1110
 
@@ -1054,10 +1215,10 @@ async function requestDelete(eid) {
1054
1215
  (slide >= 0 ? ` on slide ${slide + 1}` : "") + " will be removed from the slide.";
1055
1216
  $("delete-files").innerHTML = info.files.map((f, i) => f.usedElsewhere
1056
1217
  ? `<div class="file kept"><span>📄</span><span><code>${esc(f.rel)}</code>
1057
- <span class="why">Still used elsewhere in the deck, so it is kept.</span></span></div>`
1218
+ <span class="why">${f.usedBy?.length ? `Also used by ${esc(f.usedBy.join(", "))}` : "Still used elsewhere in the deck"}, so it is kept.</span></span></div>`
1058
1219
  : `<label class="file"><input type="checkbox" data-file="${esc(f.rel)}" checked>
1059
1220
  <span>Also delete <code>${esc(f.rel)}</code> (${fmtBytes(f.bytes)})
1060
- <span class="why">Not used anywhere else in the deck.</span></span></label>`).join("");
1221
+ <span class="why">Not used anywhere else in the deck, or by the other decks in its folder.</span></span></label>`).join("");
1061
1222
  const choice = await new Promise((resolve) => {
1062
1223
  const d = $("delete-dialog");
1063
1224
  d.addEventListener("close", () => resolve(d.returnValue), { once: true });
@@ -1090,7 +1251,7 @@ const kindOf = (f) => (f.type.startsWith("image/") || IMAGE_EXT.test(f.name) ? "
1090
1251
  const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
1091
1252
 
1092
1253
  async function uploadBlob(blob, name) {
1093
- const res = await fetch("/api/upload", { method: "POST", body: blob,
1254
+ const res = await fetch(BASE + "api/upload", { method: "POST", body: blob,
1094
1255
  headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
1095
1256
  const d = await res.json().catch(() => ({}));
1096
1257
  if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
@@ -1178,7 +1339,7 @@ function renderAttachments() {
1178
1339
  a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}` : "",
1179
1340
  fmtBytes(a.bytes), a.droppedAt ? `dropped on slide ${a.droppedAt.slide.index + 1}` : ""].filter(Boolean).join(" · ");
1180
1341
  return `<span class="att ${a.status}" title="${esc(a.path || a.name)}">
1181
- ${a.kind === "image" ? `<img class="thumb" src="${a.preview || "/deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
1342
+ ${a.kind === "image" ? `<img class="thumb" src="${a.preview || BASE + "deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
1182
1343
  <span class="meta"><b>${esc(a.path ? a.path.split("/").pop() : a.name)}</b><span>${esc(info)}</span></span>
1183
1344
  ${a.status === "uploading" ? '<span class="spinner"></span>' : ""}
1184
1345
  <button class="x" data-unattach="${a.id}" aria-label="Remove ${esc(a.name)}">×</button></span>`;