pindeck 3.0.0-beta.2 → 3.1.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");
@@ -83,7 +88,7 @@ window.__editorHost = {
83
88
  },
84
89
  slideChanged(index) {
85
90
  state.current = index;
86
- try { localStorage.setItem("editor.slide", String(index)); } catch {}
91
+ try { localStorage.setItem(SLIDE_KEY, String(index)); } catch {}
87
92
  renderSlides();
88
93
  renderToolbar();
89
94
  loadNotes();
@@ -229,7 +234,8 @@ function renderSlides() {
229
234
  ${s.kind !== "content" ? `<span class="kind">${esc(s.kind.replace(/-slide$/, ""))}</span>` : ""}
230
235
  ${s.hidden ? '<span class="kind hid">hidden</span>' : ""}</span>
231
236
  ${counts[i] ? `<span class="badge" title="${counts[i]} pin(s)">${counts[i]}</span>` : "<span></span>"}
232
- <button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : "Hide this slide (skipped when presenting)"}"
237
+ <button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : state.status?.slideSelector ?
238
+ "Hide this slide (left out of PDFs)" : "Hide this slide (skipped when presenting)"}"
233
239
  aria-label="${s.hidden ? "Show" : "Hide"} slide ${i + 1}">${s.hidden ? EYE_ICON : EYE_OFF_ICON}</button>
234
240
  </li>`).join("");
235
241
  $("slide-list").querySelector("li.active")?.scrollIntoView({ block: "nearest" });
@@ -296,7 +302,7 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
296
302
  if (!res.ok) return toast(d.error || "Could not move the slide.");
297
303
  if (d.unchanged) return;
298
304
  state.current = d.index; // the reload opens the moved slide
299
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
305
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
300
306
  loadMain();
301
307
  }
302
308
 
@@ -365,7 +371,7 @@ notesBox.addEventListener("keydown", (e) => {
365
371
  // closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
366
372
  addEventListener("pagehide", () => {
367
373
  if (notesDirty() && !state.busy)
368
- navigator.sendBeacon("/api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
374
+ navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
369
375
  });
370
376
  function setNotesOpen(open) {
371
377
  $("notes-panel").classList.toggle("collapsed", !open);
@@ -464,6 +470,31 @@ function wireMenu(btnId, menuId, beforeOpen) {
464
470
  const closeMenus = () => setMenu(null, null, false);
465
471
  document.addEventListener("click", (e) => { if (!e.target.closest(".menu")) closeMenus(); });
466
472
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }, true);
473
+ /* The deck menu, behind the front only: the decks in the deck's folder and the asset roots (and one folder
474
+ down). Each is a link, so a deck opens in place, or in a new tab with ⌘-click; the deck left behind keeps
475
+ its chat and undo, and the assistant goes on with a turn it has begun. */
476
+ if (FRONT) {
477
+ $("deck-btn").disabled = false;
478
+ $("deck-btn").title = "Open another deck";
479
+ $("deck-btn").querySelector(".caret").hidden = false;
480
+ wireMenu("deck-btn", "deck-menu", async () => {
481
+ const menu = $("deck-menu");
482
+ menu.innerHTML = '<div class="menu-note">Looking for decks…</div>';
483
+ let decks = [];
484
+ try { decks = (await (await fetch("/api/decks")).json()).decks; }
485
+ catch { menu.innerHTML = '<div class="menu-note">The deck list is not available.</div>'; return; }
486
+ const roots = [...new Set(decks.map((d) => d.root))];
487
+ menu.innerHTML = roots.map((root) => (roots.length > 1 ? `<div class="group" title="${esc(root)}">${esc(root.split("/").pop())}</div>` : "") +
488
+ decks.filter((d) => d.root === root).map((d) => {
489
+ const here = BASE === `/d/${d.key}/`;
490
+ return d.problem
491
+ ? `<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>`
492
+ : `<a class="opt" role="menuitem" href="/d/${esc(d.key)}/" ${here ? 'aria-current="page"' : ""}><span class="tick">${here ? "✓" : ""}</span>` +
493
+ `<b>${esc(d.rel)}</b>${here ? "" : d.open ? '<span class="d">open</span>' : ""}</a>`;
494
+ }).join("")).join("") +
495
+ '<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>';
496
+ });
497
+ }
467
498
  const setExportMenu = (open) => setMenu($("export-btn"), $("export-menu"), open);
468
499
  wireMenu("export-btn", "export-menu");
469
500
  $("export-menu").addEventListener("click", async (e) => {
@@ -492,8 +523,7 @@ $("export-menu").addEventListener("click", async (e) => {
492
523
  });
493
524
 
494
525
  $("present").onclick = () => {
495
- const ix = main?.bridge.indices(state.current) || { h: 0, v: 0 };
496
- window.open(`/deck/#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
526
+ window.open(`${BASE}deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
497
527
  };
498
528
 
499
529
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -571,17 +601,21 @@ function md(s) {
571
601
  }).join("");
572
602
  return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
573
603
  }
604
+ /* A screenshot in the chat: a small picture that opens large. Only the newest ones are kept on disk;
605
+ an older one says so (see the error listener below). */
606
+ const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
607
+ `<img src="${BASE}shots/${encodeURIComponent(file)}" alt="" loading="lazy">${label ? ` ${esc(label)}` : ""}</button>`;
574
608
  function entryHtml(e) {
575
609
  switch (e.kind) {
576
610
  case "user":
577
611
  return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
578
612
  `<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
613
  `<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>` : ""}`;
614
+ e.screenshot?.file ? shotChip(e.screenshot.file, `screenshot S${e.screenshot.slide}`) : e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
615
+ `<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
616
  case "assistant": return md(e.text);
583
617
  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>`;
618
+ 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
619
  case "edit": {
586
620
  const t = state.turns.get(e.turnId);
587
621
  return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
@@ -629,7 +663,23 @@ function clearChat() {
629
663
  $("chat-empty").hidden = false;
630
664
  }
631
665
 
666
+ // a picture that is no longer kept: the chip says so and opens nothing (errors do not bubble: capture)
667
+ chatEl.addEventListener("error", (e) => {
668
+ const chip = e.target.closest?.("[data-shot]");
669
+ if (!chip || e.target.tagName !== "IMG") return;
670
+ chip.removeAttribute("data-shot");
671
+ chip.classList.add("gone");
672
+ chip.title = "Only the newest screenshots are kept";
673
+ chip.textContent = "📷 no longer kept";
674
+ }, true);
632
675
  chatEl.addEventListener("click", async (e) => {
676
+ const s = e.target.closest("[data-shot]");
677
+ if (s) {
678
+ $("shot-img").src = `${BASE}shots/${encodeURIComponent(s.dataset.shot)}`;
679
+ $("shot-title").textContent = s.dataset.shot;
680
+ $("shot-dialog").showModal();
681
+ return;
682
+ }
633
683
  const d = e.target.closest("[data-diff]");
634
684
  if (d) {
635
685
  const res = await api(`turn/${d.dataset.diff}/diff`);
@@ -651,6 +701,9 @@ chatEl.addEventListener("click", async (e) => {
651
701
  }
652
702
  });
653
703
  $("diff-close").onclick = () => $("diff-dialog").close();
704
+ $("shot-close").onclick = () => $("shot-dialog").close();
705
+ // Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
706
+ $("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
654
707
 
655
708
  function setBusy(busy) {
656
709
  state.busy = busy;
@@ -739,6 +792,7 @@ document.addEventListener("keydown", (e) => {
739
792
  });
740
793
 
741
794
  /* ---- Server events ---------------------------------------------------------------------------------- */
795
+ let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
742
796
  function applyStatus(s) {
743
797
  state.status = s;
744
798
  // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
@@ -746,7 +800,7 @@ function applyStatus(s) {
746
800
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
747
801
  const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
748
802
  $("deck-name").textContent = name;
749
- $("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
803
+ $("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
750
804
  if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
751
805
  document.title = `${name} · PinDeck`;
752
806
  }
@@ -785,10 +839,13 @@ function applyStatus(s) {
785
839
  $("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
786
840
  $("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
787
841
  $("add-slide-btn").disabled = !!s.busy;
842
+ // the deck's slide tags stopped working while PinDeck runs (at start, such a deck is refused)
843
+ if (s.deckProblem && s.deckProblem !== shownProblem) toast(s.deckProblem, { ms: 20000 });
844
+ shownProblem = s.deckProblem;
788
845
  renderToolbar();
789
846
  }
790
847
  function connect() {
791
- const es = new EventSource("/api/events");
848
+ const es = new EventSource(BASE + "api/events");
792
849
  es.addEventListener("hello", (ev) => {
793
850
  const d = JSON.parse(ev.data);
794
851
  // reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
@@ -864,7 +921,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
864
921
  if (!res.ok) return toast(d.error || "Could not add the slide.");
865
922
  state.current = d.index; // the reload opens the new slide ...
866
923
  state.pendingTitleEdit = item.dataset.slide !== "duplicate"; // ... with its title ready for typing
867
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
924
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
868
925
  loadMain();
869
926
  });
870
927
 
@@ -877,7 +934,7 @@ $("import-file").addEventListener("change", async () => {
877
934
  $("import-file").value = "";
878
935
  if (!file) return;
879
936
  toast(`Reading ${file.name}…`);
880
- const res = await fetch("/api/import", { method: "POST", body: file,
937
+ const res = await fetch(BASE + "api/import", { method: "POST", body: file,
881
938
  headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
882
939
  const d = await res.json().catch(() => ({}));
883
940
  if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
@@ -921,7 +978,7 @@ function showImportThumbs(ev) {
921
978
  for (const c of importChecks()) {
922
979
  const img = new Image();
923
980
  img.alt = "";
924
- img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}`;
981
+ img.src = `${BASE}api/import-thumb?id=${importing.id}&n=${c.value}`;
925
982
  c.closest(".thumb").append(img);
926
983
  }
927
984
  }
@@ -939,7 +996,7 @@ $("import-dialog").addEventListener("close", async () => {
939
996
  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).` : "") +
940
997
  (d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
941
998
  state.current = d.index;
942
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
999
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
943
1000
  loadMain();
944
1001
  });
945
1002
 
@@ -1086,7 +1143,7 @@ const kindOf = (f) => (f.type.startsWith("image/") || IMAGE_EXT.test(f.name) ? "
1086
1143
  const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
1087
1144
 
1088
1145
  async function uploadBlob(blob, name) {
1089
- const res = await fetch("/api/upload", { method: "POST", body: blob,
1146
+ const res = await fetch(BASE + "api/upload", { method: "POST", body: blob,
1090
1147
  headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
1091
1148
  const d = await res.json().catch(() => ({}));
1092
1149
  if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
@@ -1174,7 +1231,7 @@ function renderAttachments() {
1174
1231
  a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}` : "",
1175
1232
  fmtBytes(a.bytes), a.droppedAt ? `dropped on slide ${a.droppedAt.slide.index + 1}` : ""].filter(Boolean).join(" · ");
1176
1233
  return `<span class="att ${a.status}" title="${esc(a.path || a.name)}">
1177
- ${a.kind === "image" ? `<img class="thumb" src="${a.preview || "/deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
1234
+ ${a.kind === "image" ? `<img class="thumb" src="${a.preview || BASE + "deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
1178
1235
  <span class="meta"><b>${esc(a.path ? a.path.split("/").pop() : a.name)}</b><span>${esc(info)}</span></span>
1179
1236
  ${a.status === "uploading" ? '<span class="spinner"></span>' : ""}
1180
1237
  <button class="x" data-unattach="${a.id}" aria-label="Remove ${esc(a.name)}">×</button></span>`;
@@ -0,0 +1,14 @@
1
+ /* The front's own pages: the list of decks (a folder without index.html) and why a deck cannot be opened */
2
+ body { margin: 0; background: #f6f6f4; color: #1d1f22; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
3
+ .front { max-width: 44em; margin: 3em auto; padding: 0 16px; }
4
+ .front h1 { font-size: 22px; margin: 0 0 .8em; }
5
+ .front h2 { font-size: 12px; font-weight: 600; color: #6b6f76; margin: 1.6em 0 .4em; overflow-wrap: anywhere; }
6
+ .front ul { list-style: none; margin: 0; padding: 0; border: 1px solid #e3e3df; border-radius: 10px; background: #fff; }
7
+ .front li { padding: 9px 14px; border-top: 1px solid #ececea; }
8
+ .front li:first-child { border-top: 0; }
9
+ .front li a { color: #1d1f22; font-weight: 500; text-decoration: none; }
10
+ .front li a:hover { color: #e8710a; }
11
+ .front li small { color: #6b6f76; margin-left: 6px; }
12
+ .front li.off { color: #8a8e94; }
13
+ .front li.off small { display: block; margin: 2px 0 0; font-size: 12px; }
14
+ .front pre { white-space: pre-wrap; background: #fff; border: 1px solid #e3e3df; border-radius: 8px; padding: 10px 12px; font-size: 13px; }
package/public/index.html CHANGED
@@ -35,7 +35,10 @@
35
35
  <button id="prev" class="icon" title="Previous slide (←)" aria-label="Previous slide">‹</button>
36
36
  <span id="slide-pos" class="pos">– / –</span>
37
37
  <button id="next" class="icon" title="Next slide (→)" aria-label="Next slide">›</button>
38
- <span id="deck-name" class="deck-name"></span>
38
+ <span class="menu-wrap deck-wrap">
39
+ <button id="deck-btn" class="deck-btn" aria-haspopup="menu" aria-expanded="false" disabled><span id="deck-name"></span><span class="caret" hidden>▾</span></button>
40
+ <div id="deck-menu" class="menu left deck-menu" role="menu" hidden></div>
41
+ </span>
39
42
  <span id="server-note" class="server-note" hidden></span>
40
43
  </div>
41
44
  <div class="group">
@@ -172,6 +175,11 @@
172
175
  </form>
173
176
  </dialog>
174
177
 
178
+ <dialog id="shot-dialog" class="diff-dialog shot-dialog">
179
+ <div class="dialog-head"><span id="shot-title">Screenshot</span><button class="small" id="shot-close">Close</button></div>
180
+ <img id="shot-img" alt="Screenshot">
181
+ </dialog>
182
+
175
183
  <dialog id="diff-dialog" class="diff-dialog">
176
184
  <div class="dialog-head"><span id="diff-title">Changes</span><button class="small" id="diff-close">Close</button></div>
177
185
  <pre id="diff-body" class="diff"></pre>