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

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,13 +1,22 @@
1
1
  /* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
2
2
 
3
- const TOKEN = document.querySelector('meta[name="editor-token"]').content;
4
3
  const $ = (id) => document.getElementById(id);
5
4
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
6
- const api = (p, body) => fetch("/api/" + p, {
7
- method: body === undefined ? "GET" : "POST",
8
- headers: { "x-editor-token": TOKEN, "content-type": "application/json" },
9
- body: body === undefined ? undefined : JSON.stringify(body),
10
- });
5
+ /* Changes to the deck carry the revision of the files this tab shows (state.rev); the server refuses them
6
+ (409, code "stale") if the files have changed since. A change that went through gives the new revision. */
7
+ const CHANGES = new Set(["edit", "text", "split", "remove", "notes", "slide-hidden", "move-slide", "add-slide",
8
+ "delete", "import-apply", "undo", "redo"]);
9
+ const api = async (p, body) => {
10
+ const change = body !== undefined && CHANGES.has(p.split("?")[0]);
11
+ if (change && state.rev) body = { ...body, rev: state.rev };
12
+ const res = await fetch("/api/" + p, {
13
+ method: body === undefined ? "GET" : "POST",
14
+ headers: { "content-type": "application/json" },
15
+ body: body === undefined ? undefined : JSON.stringify(body),
16
+ });
17
+ if (change && res.ok && res.headers.get("x-deck-rev")) state.rev = res.headers.get("x-deck-rev");
18
+ return res;
19
+ };
11
20
 
12
21
  const savedSlide = (() => { try { return +localStorage.getItem("editor.slide") || 0; } catch { return 0; } })();
13
22
  const state = {
@@ -22,7 +31,8 @@ let measure = null; // { iframe, bridge }
22
31
  let measureReady = null, measureResolve = null;
23
32
 
24
33
  // showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
25
- function deckSrc(mode, extra = "") { return `/deck/index.html?editor=${mode}&showHiddenSlides=true${extra}`; }
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}`; }
26
36
 
27
37
  function loadMain() {
28
38
  const f = document.createElement("iframe");
@@ -102,7 +112,11 @@ window.__editorHost = {
102
112
  commitText(edit) {
103
113
  queueEdit(async () => {
104
114
  const res = await api("text", edit);
105
- if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not save the text.");
115
+ if (!res.ok) {
116
+ const d = await res.json().catch(() => ({}));
117
+ if (d.code === "stale") keepRejectedText(edit.text, d);
118
+ else toast(d.error || "Could not save the text.");
119
+ }
106
120
  loadMain(); // re-render: math, footer numbers, and the file as saved
107
121
  }, "Could not save the text.");
108
122
  },
@@ -171,6 +185,19 @@ async function undoRedo(which) {
171
185
  const res = await api(which, {});
172
186
  if (!res.ok) toast((await res.json().catch(() => ({}))).error || `Nothing to ${which}.`);
173
187
  }
188
+ /* A text edit refused because the file changed meanwhile: the slide reloads with the file as it is now,
189
+ and the user's text stays available to copy and type in again. Nothing is merged automatically. */
190
+ let rejectedText = "";
191
+ function keepRejectedText(text, d) {
192
+ if (d.rev) state.rev = d.rev;
193
+ rejectedText = String(text ?? "");
194
+ toast(`${esc(d.error)}<a href="#" data-copy-rejected>Copy your text</a>`, { html: true, ms: 20000 });
195
+ }
196
+ document.addEventListener("click", (e) => {
197
+ if (!e.target.closest?.("[data-copy-rejected]")) return;
198
+ e.preventDefault();
199
+ navigator.clipboard?.writeText(rejectedText).then(() => toast("Your text is on the clipboard."), () => toast(rejectedText, { ms: 20000 }));
200
+ });
174
201
  let toastTimer = null;
175
202
  function toast(msg, { html = false, ms = 3200 } = {}) {
176
203
  $("toast")[html ? "innerHTML" : "textContent"] = msg;
@@ -202,7 +229,8 @@ function renderSlides() {
202
229
  ${s.kind !== "content" ? `<span class="kind">${esc(s.kind.replace(/-slide$/, ""))}</span>` : ""}
203
230
  ${s.hidden ? '<span class="kind hid">hidden</span>' : ""}</span>
204
231
  ${counts[i] ? `<span class="badge" title="${counts[i]} pin(s)">${counts[i]}</span>` : "<span></span>"}
205
- <button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : "Hide this slide (skipped when presenting)"}"
232
+ <button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : state.status?.slideSelector ?
233
+ "Hide this slide (left out of PDFs)" : "Hide this slide (skipped when presenting)"}"
206
234
  aria-label="${s.hidden ? "Show" : "Hide"} slide ${i + 1}">${s.hidden ? EYE_ICON : EYE_OFF_ICON}</button>
207
235
  </li>`).join("");
208
236
  $("slide-list").querySelector("li.active")?.scrollIntoView({ block: "nearest" });
@@ -338,7 +366,7 @@ notesBox.addEventListener("keydown", (e) => {
338
366
  // closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
339
367
  addEventListener("pagehide", () => {
340
368
  if (notesDirty() && !state.busy)
341
- navigator.sendBeacon(`/api/notes?t=${TOKEN}`, new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
369
+ navigator.sendBeacon("/api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
342
370
  });
343
371
  function setNotesOpen(open) {
344
372
  $("notes-panel").classList.toggle("collapsed", !open);
@@ -465,8 +493,7 @@ $("export-menu").addEventListener("click", async (e) => {
465
493
  });
466
494
 
467
495
  $("present").onclick = () => {
468
- const ix = main?.bridge.indices(state.current) || { h: 0, v: 0 };
469
- window.open(`/deck/index.html#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
496
+ window.open(`/deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
470
497
  };
471
498
 
472
499
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -567,13 +594,14 @@ function entryHtml(e) {
567
594
  if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
568
595
  if (e.files?.length) {
569
596
  parts.push(`<span class="files ${t?.undone ? "undone" : ""}">Changed ${e.files.map((f) =>
570
- `${esc(f.file)} <span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`).join(", ")}</span>`);
597
+ `${esc(f.file)} ` + (f.binary ? `<span class="add">(binary)</span>`
598
+ : `<span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`)).join(", ")}</span>`);
571
599
  parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
572
600
  if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
573
601
  if (t?.undone) parts.push("<span>undone</span>");
574
602
  } else if (!e.error) parts.push("No file changes");
575
603
  if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
576
- 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 index.html and theme.css">` +
604
+ 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">` +
577
605
  `Not allowed in PinDeck, so skipped: ${e.denials.map((d) => esc(`${d.name} ${d.detail}`)).join(", ")}</span>`);
578
606
  if (e.duration) parts.push(`<span class="meta">${(e.duration / 1000).toFixed(1)} s</span>`);
579
607
  return parts.join(" ");
@@ -711,14 +739,17 @@ document.addEventListener("keydown", (e) => {
711
739
  });
712
740
 
713
741
  /* ---- Server events ---------------------------------------------------------------------------------- */
742
+ let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
714
743
  function applyStatus(s) {
715
744
  state.status = s;
716
- // the deck's name in the header ("ocean-example"), its full path on hover
745
+ // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
717
746
  if (s.deck) {
718
747
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
719
- $("deck-name").textContent = folder;
748
+ const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
749
+ $("deck-name").textContent = name;
720
750
  $("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
721
- document.title = `${folder} · PinDeck`;
751
+ if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
752
+ document.title = `${name} · PinDeck`;
722
753
  }
723
754
  setBusy(!!s.busy);
724
755
  $("model").textContent = modelLabel(s);
@@ -730,15 +761,43 @@ function applyStatus(s) {
730
761
  $("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
731
762
  $("conn").classList.toggle("on", !!s.running);
732
763
  $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
764
+ // only the selected backend's availability matters: Codex missing is no news to someone using Claude
765
+ const provider = s.settings?.provider || "claude";
766
+ const caps = Object.fromEntries(Object.entries(s.capabilities || {})
767
+ .filter(([k]) => !["claude", "codex"].includes(k) || k === provider));
768
+ const why = (c) => (c && c.available === false ? c.reason : "");
733
769
  $("add-shot").disabled = !s.chrome;
734
- $("add-shot").title = s.chrome ? "" : "Screenshots need Chrome or Chromium";
770
+ $("add-shot").title = s.chrome ? "" : why(caps.screenshot) || "Screenshots need Chrome or Chromium";
771
+ for (const item of $("export-menu").querySelectorAll("[data-export]")) {
772
+ item.disabled = !!why(caps.pdfExport);
773
+ item.title = why(caps.pdfExport);
774
+ }
775
+ const imp = $("add-slide-menu").querySelector('[data-slide="import"]');
776
+ imp.disabled = !!why(caps.pptxImport);
777
+ imp.title = why(caps.pptxImport);
778
+ if (why(caps[provider])) $("conn").title = caps[provider].reason;
779
+ // where the server runs, when it is not this Mac's usual setup: "Linux (beta)", with what is off
780
+ const names = { claude: "Claude", codex: "OpenAI Codex", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
781
+ pdfExport: "PDF export", pdfPages: "Pictures of PDF pages", pptxImport: "PowerPoint import",
782
+ pptxPictures: "Pictures of PowerPoint slides", audioConvert: "Waveforms of non-WAV audio" };
783
+ const off = Object.entries(caps).filter(([, c]) => c && c.available === false).map(([k]) => "• " + (names[k] || k));
784
+ const reasons = [...new Set(Object.values(caps).filter((c) => c && c.available === false).map((c) => c.reason))];
785
+ $("server-note").hidden = !caps.platform || caps.platform === "darwin";
786
+ $("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
787
+ $("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
735
788
  $("add-slide-btn").disabled = !!s.busy;
789
+ // the deck's slide tags stopped working while PinDeck runs (at start, such a deck is refused)
790
+ if (s.deckProblem && s.deckProblem !== shownProblem) toast(s.deckProblem, { ms: 20000 });
791
+ shownProblem = s.deckProblem;
736
792
  renderToolbar();
737
793
  }
738
794
  function connect() {
739
- const es = new EventSource(`/api/events?t=${TOKEN}`);
795
+ const es = new EventSource("/api/events");
740
796
  es.addEventListener("hello", (ev) => {
741
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;
742
801
  clearChat();
743
802
  state.turns = new Map(d.turns.map((t) => [t.id, t]));
744
803
  d.chat.forEach(renderEntry);
@@ -762,7 +821,8 @@ function connect() {
762
821
  rerenderTurns();
763
822
  });
764
823
  es.addEventListener("files", (ev) => {
765
- const { files, manual } = JSON.parse(ev.data);
824
+ const { files, manual, rev } = JSON.parse(ev.data);
825
+ if (rev) state.rev = rev;
766
826
  const cssOnly = files.every((f) => f.endsWith(".css"));
767
827
  if (manual && main) {
768
828
  loadMeasure(); // the visible slide already shows the hand edit
@@ -779,7 +839,8 @@ function connect() {
779
839
  try { api("measure-result", { id, result: await queueMeasure(index) }); }
780
840
  catch (err) { api("measure-result", { id, error: String(err) }); }
781
841
  });
782
- // EventSource reconnects by itself; if the server restarted, our token is stale: reload the page
842
+ // EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
843
+ // which asks for a new login link
783
844
  es.onerror = async () => {
784
845
  try { if ((await api("ping")).status === 403) location.reload(); } catch {}
785
846
  };
@@ -821,7 +882,7 @@ $("import-file").addEventListener("change", async () => {
821
882
  if (!file) return;
822
883
  toast(`Reading ${file.name}…`);
823
884
  const res = await fetch("/api/import", { method: "POST", body: file,
824
- headers: { "x-editor-token": TOKEN, "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
885
+ headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
825
886
  const d = await res.json().catch(() => ({}));
826
887
  if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
827
888
  importing = d;
@@ -864,7 +925,7 @@ function showImportThumbs(ev) {
864
925
  for (const c of importChecks()) {
865
926
  const img = new Image();
866
927
  img.alt = "";
867
- img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}&t=${TOKEN}`;
928
+ img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}`;
868
929
  c.closest(".thumb").append(img);
869
930
  }
870
931
  }
@@ -1030,7 +1091,7 @@ const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Ma
1030
1091
 
1031
1092
  async function uploadBlob(blob, name) {
1032
1093
  const res = await fetch("/api/upload", { method: "POST", body: blob,
1033
- headers: { "x-editor-token": TOKEN, "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
1094
+ headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
1034
1095
  const d = await res.json().catch(() => ({}));
1035
1096
  if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
1036
1097
  return d;
package/public/index.html CHANGED
@@ -3,7 +3,6 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta name="editor-token" content="%%TOKEN%%">
7
6
  <title>PinDeck</title>
8
7
  <link rel="icon" href="/editor/favicon.svg" type="image/svg+xml">
9
8
  <link rel="icon" href="/editor/favicon.png" type="image/png" sizes="64x64">
@@ -37,6 +36,7 @@
37
36
  <span id="slide-pos" class="pos">– / –</span>
38
37
  <button id="next" class="icon" title="Next slide (→)" aria-label="Next slide">›</button>
39
38
  <span id="deck-name" class="deck-name"></span>
39
+ <span id="server-note" class="server-note" hidden></span>
40
40
  </div>
41
41
  <div class="group">
42
42
  <button id="undo" class="icon" aria-label="Undo" title="Undo (⌘Z)" disabled>↶</button>
@@ -99,7 +99,7 @@
99
99
  double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
100
100
  <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
101
101
  or, for a paper or report, take the details from it.</p>
102
- <p>The assistant edits <code>index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
102
+ <p>The assistant edits <code id="deck-file">index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
103
103
  <code>brand.css</code> is locked.</p>
104
104
  </div>
105
105
  </div>