pindeck 2.8.2 → 2.9.0

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 Claude Code. */
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 = {
@@ -102,7 +111,11 @@ window.__editorHost = {
102
111
  commitText(edit) {
103
112
  queueEdit(async () => {
104
113
  const res = await api("text", edit);
105
- if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not save the text.");
114
+ if (!res.ok) {
115
+ const d = await res.json().catch(() => ({}));
116
+ if (d.code === "stale") keepRejectedText(edit.text, d);
117
+ else toast(d.error || "Could not save the text.");
118
+ }
106
119
  loadMain(); // re-render: math, footer numbers, and the file as saved
107
120
  }, "Could not save the text.");
108
121
  },
@@ -171,6 +184,19 @@ async function undoRedo(which) {
171
184
  const res = await api(which, {});
172
185
  if (!res.ok) toast((await res.json().catch(() => ({}))).error || `Nothing to ${which}.`);
173
186
  }
187
+ /* A text edit refused because the file changed meanwhile: the slide reloads with the file as it is now,
188
+ and the user's text stays available to copy and type in again. Nothing is merged automatically. */
189
+ let rejectedText = "";
190
+ function keepRejectedText(text, d) {
191
+ if (d.rev) state.rev = d.rev;
192
+ rejectedText = String(text ?? "");
193
+ toast(`${esc(d.error)}<a href="#" data-copy-rejected>Copy your text</a>`, { html: true, ms: 20000 });
194
+ }
195
+ document.addEventListener("click", (e) => {
196
+ if (!e.target.closest?.("[data-copy-rejected]")) return;
197
+ e.preventDefault();
198
+ navigator.clipboard?.writeText(rejectedText).then(() => toast("Your text is on the clipboard."), () => toast(rejectedText, { ms: 20000 }));
199
+ });
174
200
  let toastTimer = null;
175
201
  function toast(msg, { html = false, ms = 3200 } = {}) {
176
202
  $("toast")[html ? "innerHTML" : "textContent"] = msg;
@@ -338,7 +364,7 @@ notesBox.addEventListener("keydown", (e) => {
338
364
  // closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
339
365
  addEventListener("pagehide", () => {
340
366
  if (notesDirty() && !state.busy)
341
- navigator.sendBeacon(`/api/notes?t=${TOKEN}`, new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
367
+ navigator.sendBeacon("/api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
342
368
  });
343
369
  function setNotesOpen(open) {
344
370
  $("notes-panel").classList.toggle("collapsed", !open);
@@ -725,15 +751,37 @@ function applyStatus(s) {
725
751
  $("model-btn").disabled = !!s.busy;
726
752
  $("conn").classList.toggle("on", !!s.running);
727
753
  $("conn").title = s.running ? "Claude Code is running" : "Claude Code starts with the next message";
754
+ const caps = s.capabilities || {};
755
+ const why = (c) => (c && c.available === false ? c.reason : "");
728
756
  $("add-shot").disabled = !s.chrome;
729
- $("add-shot").title = s.chrome ? "" : "Screenshots need Chrome or Chromium";
757
+ $("add-shot").title = s.chrome ? "" : why(caps.screenshot) || "Screenshots need Chrome or Chromium";
758
+ for (const item of $("export-menu").querySelectorAll("[data-export]")) {
759
+ item.disabled = !!why(caps.pdfExport);
760
+ item.title = why(caps.pdfExport);
761
+ }
762
+ const imp = $("add-slide-menu").querySelector('[data-slide="import"]');
763
+ imp.disabled = !!why(caps.pptxImport);
764
+ imp.title = why(caps.pptxImport);
765
+ if (why(caps.claude)) $("conn").title = caps.claude.reason;
766
+ // where the server runs, when it is not this Mac's usual setup: "Linux (beta)", with what is off
767
+ const names = { claude: "Claude", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
768
+ pdfExport: "PDF export", pdfPages: "Pictures of PDF pages", pptxImport: "PowerPoint import",
769
+ pptxPictures: "Pictures of PowerPoint slides", audioConvert: "Waveforms of non-WAV audio" };
770
+ const off = Object.entries(caps).filter(([, c]) => c && c.available === false).map(([k]) => "• " + (names[k] || k));
771
+ const reasons = [...new Set(Object.values(caps).filter((c) => c && c.available === false).map((c) => c.reason))];
772
+ $("server-note").hidden = !caps.platform || caps.platform === "darwin";
773
+ $("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
774
+ $("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
730
775
  $("add-slide-btn").disabled = !!s.busy;
731
776
  renderToolbar();
732
777
  }
733
778
  function connect() {
734
- const es = new EventSource(`/api/events?t=${TOKEN}`);
779
+ const es = new EventSource("/api/events");
735
780
  es.addEventListener("hello", (ev) => {
736
781
  const d = JSON.parse(ev.data);
782
+ // reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
783
+ if (state.rev && d.rev && d.rev !== state.rev && main) { loadMain(); loadMeasure(); }
784
+ if (d.rev) state.rev = d.rev;
737
785
  clearChat();
738
786
  state.turns = new Map(d.turns.map((t) => [t.id, t]));
739
787
  d.chat.forEach(renderEntry);
@@ -757,7 +805,8 @@ function connect() {
757
805
  rerenderTurns();
758
806
  });
759
807
  es.addEventListener("files", (ev) => {
760
- const { files, manual } = JSON.parse(ev.data);
808
+ const { files, manual, rev } = JSON.parse(ev.data);
809
+ if (rev) state.rev = rev;
761
810
  const cssOnly = files.every((f) => f.endsWith(".css"));
762
811
  if (manual && main) {
763
812
  loadMeasure(); // the visible slide already shows the hand edit
@@ -774,7 +823,8 @@ function connect() {
774
823
  try { api("measure-result", { id, result: await queueMeasure(index) }); }
775
824
  catch (err) { api("measure-result", { id, error: String(err) }); }
776
825
  });
777
- // EventSource reconnects by itself; if the server restarted, our token is stale: reload the page
826
+ // EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
827
+ // which asks for a new login link
778
828
  es.onerror = async () => {
779
829
  try { if ((await api("ping")).status === 403) location.reload(); } catch {}
780
830
  };
@@ -816,7 +866,7 @@ $("import-file").addEventListener("change", async () => {
816
866
  if (!file) return;
817
867
  toast(`Reading ${file.name}…`);
818
868
  const res = await fetch("/api/import", { method: "POST", body: file,
819
- headers: { "x-editor-token": TOKEN, "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
869
+ headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
820
870
  const d = await res.json().catch(() => ({}));
821
871
  if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
822
872
  importing = d;
@@ -859,7 +909,7 @@ function showImportThumbs(ev) {
859
909
  for (const c of importChecks()) {
860
910
  const img = new Image();
861
911
  img.alt = "";
862
- img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}&t=${TOKEN}`;
912
+ img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}`;
863
913
  c.closest(".thumb").append(img);
864
914
  }
865
915
  }
@@ -976,7 +1026,7 @@ const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Ma
976
1026
 
977
1027
  async function uploadBlob(blob, name) {
978
1028
  const res = await fetch("/api/upload", { method: "POST", body: blob,
979
- headers: { "x-editor-token": TOKEN, "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
1029
+ headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
980
1030
  const d = await res.json().catch(() => ({}));
981
1031
  if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
982
1032
  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>