pindeck 3.1.0-beta.1 → 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
@@ -47,10 +47,22 @@ function loadMain() {
47
47
  $("stage").appendChild(f);
48
48
  if (pendingMain) pendingMain.remove();
49
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);
50
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;
51
62
  function loadMeasure() {
52
63
  measure?.iframe.remove();
53
- measureReady = new Promise((r) => (measureResolve = r));
64
+ measureReady = new Promise((resolve, reject) => { measureResolve = resolve; measureReject = reject; });
65
+ measureReady.catch(() => {});
54
66
  const f = document.createElement("iframe");
55
67
  f.className = "measure";
56
68
  f.setAttribute("aria-hidden", "true");
@@ -60,19 +72,52 @@ function loadMeasure() {
60
72
  $("stage").appendChild(f);
61
73
  }
62
74
 
63
- window.__editorHost = {
64
- 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) {
65
109
  if (role === "measure") {
66
- 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); }
67
111
  return;
68
112
  }
69
113
  if (!pendingMain || pendingMain.contentWindow !== win) return;
70
114
  const old = main;
115
+ const bridge = bridgeOf(win);
71
116
  main = { iframe: pendingMain, bridge };
72
117
  pendingMain = null;
73
118
  main.iframe.classList.remove("loading");
74
119
  old?.iframe.remove();
75
- const inf = bridge.info();
120
+ stageProblem(null);
76
121
  state.slides = inf.slides;
77
122
  state.current = Math.min(state.current, state.slides.length - 1);
78
123
  if (inf.current !== state.current) bridge.goto(state.current);
@@ -125,13 +170,20 @@ window.__editorHost = {
125
170
  loadMain(); // re-render: math, footer numbers, and the file as saved
126
171
  }, "Could not save the text.");
127
172
  },
128
- commitSplit(op, done) {
129
- queueEdit(async () => {
130
- 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);
131
178
  const d = await res.json().catch(() => ({}));
132
- if (!res.ok) { toast(d.error || "Could not add the bullet."); loadMain(); return; }
133
- done?.(d.eid);
134
- }, "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);
135
187
  },
136
188
  commitRemove(eid) {
137
189
  queueEdit(async () => {
@@ -141,7 +193,11 @@ window.__editorHost = {
141
193
  }, "Could not remove the bullet.");
142
194
  },
143
195
  refresh() { queueEdit(() => loadMain(), "Could not reload the slide."); },
144
- 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
+ },
145
201
  requestDelete(eid) { requestDelete(eid); },
146
202
  dropFiles(files, at) { attachFiles(files, at); },
147
203
  dragOver(on) { $("drop-hint").hidden = !on; },
@@ -211,9 +267,11 @@ function toast(msg, { html = false, ms = 3200 } = {}) {
211
267
  toastTimer = setTimeout(() => ($("toast").hidden = true), ms);
212
268
  }
213
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))]);
214
272
  async function measureSlide(index) {
215
- const b = await measureReady;
216
- return b.measure(index);
273
+ const b = await within(8000, measureReady, "The measuring frame");
274
+ return within(8000, b.measure(index), "Measuring the slide");
217
275
  }
218
276
  // one measurement at a time: the measure frame has a single "current slide"
219
277
  let measureChain = Promise.resolve();
@@ -523,7 +581,7 @@ $("export-menu").addEventListener("click", async (e) => {
523
581
  });
524
582
 
525
583
  $("present").onclick = () => {
526
- window.open(`${BASE}deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
584
+ window.open(`${BASE}deck/${state.slides[state.current]?.hash || ""}`, "_blank");
527
585
  };
528
586
 
529
587
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -545,7 +603,6 @@ function pinsChanged() {
545
603
  main?.bridge.renderPins(state.pins);
546
604
  renderPinTray();
547
605
  renderSlides();
548
- api("pins", { pins: state.pins });
549
606
  }
550
607
  function shortEid(p) {
551
608
  if (p.tag?.startsWith("footer")) return "footer";
@@ -565,7 +622,7 @@ function renderPinTray() {
565
622
  }
566
623
  $("pin-tray").addEventListener("click", (e) => {
567
624
  const x = e.target.closest("[data-x]");
568
- if (x) return window.__editorHost.removePin(+x.dataset.x);
625
+ if (x) return editorHost.removePin(+x.dataset.x);
569
626
  if (e.target.closest("[data-unsel]")) return main?.bridge.deselect();
570
627
  if (e.target.closest("[data-sel]")) return main?.bridge.highlight(state.sel.eid);
571
628
  const chip = e.target.closest(".chip");
@@ -605,6 +662,8 @@ function md(s) {
605
662
  an older one says so (see the error listener below). */
606
663
  const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
607
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;
608
667
  function entryHtml(e) {
609
668
  switch (e.kind) {
610
669
  case "user":
@@ -617,22 +676,23 @@ function entryHtml(e) {
617
676
  case "tool":
618
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>`;
619
678
  case "edit": {
620
- const t = state.turns.get(e.turnId);
621
- return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
622
- <button class="small" data-diff="${e.turnId}">Diff</button>
623
- ${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>" : ""}`;
624
683
  }
625
684
  case "turn": {
626
- 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;
627
687
  const parts = [];
628
688
  if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
629
689
  if (e.files?.length) {
630
- 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) =>
631
691
  `${esc(f.file)} ` + (f.binary ? `<span class="add">(binary)</span>`
632
692
  : `<span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`)).join(", ")}</span>`);
633
- parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
634
- if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
635
- 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>");
636
696
  } else if (!e.error) parts.push("No file changes");
637
697
  if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
638
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">` +
@@ -734,7 +794,15 @@ async function send() {
734
794
  ({ path, kind, name, bytes, width, height, duration, sampleRate, channels, waveform, pages, pageSize, droppedAt })),
735
795
  current: s ? { index: state.current, eid: s.eid } : null,
736
796
  });
737
- 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
+ }
738
806
  $("input").value = "";
739
807
  autosize();
740
808
  state.shot = null;
@@ -747,9 +815,9 @@ async function send() {
747
815
  }
748
816
  }
749
817
  $("send").onclick = send;
750
- $("stop").onclick = () => api("stop", {});
818
+ $("stop").onclick = () => { $("working-text").textContent = "Stopping…"; api("stop", {}); };
751
819
  $("new-chat").onclick = async () => {
752
- 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;
753
821
  await api("new-chat", {});
754
822
  };
755
823
  function autosize() {
@@ -771,7 +839,10 @@ function handleKey(k, mod = {}) {
771
839
  else if (k === "v" || k === "V") setMode("select");
772
840
  else if (k === "h" || k === "H") toggleHidden();
773
841
  else if (k === "Escape") { if (state.mode === "pin") setMode("select"); else main?.bridge.deselect(); }
774
- 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
+ }
775
846
  else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete(state.sel.eid);
776
847
  else if (k.startsWith("Arrow") && state.sel && state.mode === "select") {
777
848
  const step = mod.shift ? 10 : 1;
@@ -782,6 +853,10 @@ function handleKey(k, mod = {}) {
782
853
  }
783
854
  document.addEventListener("keydown", (e) => {
784
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;
785
860
  const meta = e.metaKey || e.ctrlKey;
786
861
  if (meta && (e.key === "z" || e.key === "Z")) { e.preventDefault(); return handleKey(e.key, { meta, shift: e.shiftKey }); }
787
862
  if (meta || e.altKey) return;
@@ -794,7 +869,10 @@ document.addEventListener("keydown", (e) => {
794
869
  /* ---- Server events ---------------------------------------------------------------------------------- */
795
870
  let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
796
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;
797
874
  state.status = s;
875
+ if (undoMoved) rerenderTurns(); // the Undo button moves to the change that is now the most recent
798
876
  // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
799
877
  if (s.deck) {
800
878
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
@@ -811,7 +889,7 @@ function applyStatus(s) {
811
889
  renderEffortMenu();
812
890
  $("new-chat").disabled = !!s.busy;
813
891
  $("stop").hidden = !s.busy || !!s.switching;
814
- $("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…";
815
893
  $("conn").classList.toggle("on", !!s.running);
816
894
  $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
817
895
  // only the selected backend's availability matters: Codex missing is no news to someone using Claude
@@ -844,59 +922,89 @@ function applyStatus(s) {
844
922
  shownProblem = s.deckProblem;
845
923
  renderToolbar();
846
924
  }
847
- function connect() {
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
+ }
848
952
  const es = new EventSource(BASE + "api/events");
849
- es.addEventListener("hello", (ev) => {
850
- const d = JSON.parse(ev.data);
851
- // reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
852
- if (state.rev && d.rev && d.rev !== state.rev && main) { loadMain(); loadMeasure(); }
853
- if (d.rev) state.rev = d.rev;
854
- clearChat();
855
- state.turns = new Map(d.turns.map((t) => [t.id, t]));
856
- d.chat.forEach(renderEntry);
857
- applyStatus(d.status);
858
- scrollChat();
859
- });
860
- es.addEventListener("chat", (ev) => {
861
- const e = JSON.parse(ev.data);
862
- if ((e.kind === "turn" || e.kind === "edit") && e.turnId) state.turns.set(e.turnId, { id: e.turnId, undone: false });
863
- renderEntry(e);
864
- scrollChat();
865
- });
866
- es.addEventListener("chat-update", (ev) => renderEntry(JSON.parse(ev.data)));
867
- es.addEventListener("import", (ev) => showImportThumbs(JSON.parse(ev.data)));
868
- es.addEventListener("status", (ev) => applyStatus(JSON.parse(ev.data)));
869
- es.addEventListener("reset", () => { clearChat(); state.turns.clear(); });
870
- es.addEventListener("undone", (ev) => {
871
- const d = JSON.parse(ev.data);
872
- const t = state.turns.get(d.turnId);
873
- if (t) t.undone = d.undone !== false;
874
- rerenderTurns();
875
- });
876
- es.addEventListener("files", (ev) => {
877
- const { files, manual, rev } = JSON.parse(ev.data);
878
- if (rev) state.rev = rev;
879
- const cssOnly = files.every((f) => f.endsWith(".css"));
880
- if (manual && main) {
881
- loadMeasure(); // the visible slide already shows the hand edit
882
- } else if (cssOnly && main) {
883
- main.bridge.reloadCss(files);
884
- measure?.bridge?.reloadCss(files);
885
- } else {
886
- loadMain();
887
- loadMeasure();
888
- }
889
- });
890
- es.addEventListener("measure", async (ev) => {
891
- const { id, index } = JSON.parse(ev.data);
892
- try { api("measure-result", { id, result: await queueMeasure(index) }); }
893
- catch (err) { api("measure-result", { id, error: String(err) }); }
894
- });
953
+ for (const [type, fn] of Object.entries(on)) es.addEventListener(type, (ev) => fn(JSON.parse(ev.data)));
954
+ es.onerror = onError;
955
+ }
956
+ function connect() {
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
+ },
895
1003
  // EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
896
1004
  // which asks for a new login link
897
- es.onerror = async () => {
1005
+ }, async () => {
898
1006
  try { if ((await api("ping")).status === 403) location.reload(); } catch {}
899
- };
1007
+ });
900
1008
  }
901
1009
 
902
1010
  /* ---- New slides --------------------------------------------------------------------------------------
@@ -1107,10 +1215,10 @@ async function requestDelete(eid) {
1107
1215
  (slide >= 0 ? ` on slide ${slide + 1}` : "") + " will be removed from the slide.";
1108
1216
  $("delete-files").innerHTML = info.files.map((f, i) => f.usedElsewhere
1109
1217
  ? `<div class="file kept"><span>📄</span><span><code>${esc(f.rel)}</code>
1110
- <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>`
1111
1219
  : `<label class="file"><input type="checkbox" data-file="${esc(f.rel)}" checked>
1112
1220
  <span>Also delete <code>${esc(f.rel)}</code> (${fmtBytes(f.bytes)})
1113
- <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("");
1114
1222
  const choice = await new Promise((resolve) => {
1115
1223
  const d = $("delete-dialog");
1116
1224
  d.addEventListener("close", () => resolve(d.returnValue), { once: true });
@@ -0,0 +1,47 @@
1
+ /* One event stream for all of this browser's PinDeck tabs on one port, behind the front.
2
+
3
+ Browsers open at most 6 connections to one host and port, and each editor tab held one open for its deck's
4
+ events, so a sixth tab never loaded and the others stalled. The tabs now share this worker's stream: each
5
+ tab (a port here) names its deck, and the front (front.mjs, /events) passes that deck's events on, marked
6
+ with the tab. Each tab still gets its deck's events as if it had its own stream, starting with "hello". */
7
+
8
+ const tabs = new Map(); // tab id -> { port, deck }
9
+ const stream = crypto.randomUUID?.() || String(Math.random()).slice(2) + String(Date.now());
10
+ let source = null, seq = 0;
11
+
12
+ const tell = (body) => fetch("/events/subscribe", { method: "POST", headers: { "content-type": "application/json" },
13
+ body: JSON.stringify({ stream, ...body }) }).catch(() => {});
14
+
15
+ function open() {
16
+ source = new EventSource(`/events?stream=${encodeURIComponent(stream)}`);
17
+ // "ready" comes first on every (re)connect: the front has a new stream, with no decks yet
18
+ source.addEventListener("ready", () => { for (const [tab, t] of tabs) tell({ tab, deck: t.deck, on: true }); });
19
+ source.addEventListener("deck", (ev) => {
20
+ const m = JSON.parse(ev.data);
21
+ tabs.get(m.tab)?.port.postMessage({ type: m.type, data: m.data });
22
+ });
23
+ source.onerror = () => { for (const t of tabs.values()) t.port.postMessage({ type: "error" }); };
24
+ }
25
+
26
+ onconnect = (e) => {
27
+ const port = e.ports[0];
28
+ let tab = null;
29
+ const leave = () => {
30
+ if (!tab) return;
31
+ tabs.delete(tab);
32
+ tell({ tab, on: false });
33
+ tab = null;
34
+ if (!tabs.size) { source?.close(); source = null; }
35
+ };
36
+ port.onmessage = ({ data }) => {
37
+ if (data?.subscribe) {
38
+ leave();
39
+ tab = String(++seq);
40
+ tabs.set(tab, { port, deck: String(data.subscribe) });
41
+ if (!source) open();
42
+ else if (source.readyState === EventSource.OPEN) tell({ tab, deck: String(data.subscribe), on: true });
43
+ }
44
+ if (data?.close) leave();
45
+ };
46
+ port.start();
47
+ };
package/public/index.html CHANGED
@@ -102,7 +102,7 @@
102
102
  double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
103
103
  <p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; the assistant can put it on a slide
104
104
  or, for a paper or report, take the details from it.</p>
105
- <p>The assistant edits <code id="deck-file">index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
105
+ <p>The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
106
106
  <code>brand.css</code> is locked.</p>
107
107
  </div>
108
108
  </div>