pindeck 2.8.2 → 2.10.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/README.md +211 -23
- package/cli.mjs +6 -3
- package/lib/capabilities.mjs +118 -0
- package/lib/claude.mjs +8 -3
- package/lib/deck-files.mjs +94 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +1 -0
- package/public/editor.js +82 -24
- package/public/index.html +2 -2
- package/server.mjs +630 -155
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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)
|
|
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" :
|
|
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(
|
|
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
|
-
|
|
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)}
|
|
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 Claude 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 Claude run only its own commands (layout, shot, slides, pdf) and edit ${esc(state.status?.deckFile || "index.html")} and theme.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,29 +739,57 @@ 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
|
-
|
|
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
|
-
|
|
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);
|
|
725
756
|
$("model-btn").disabled = !!s.busy;
|
|
726
757
|
$("conn").classList.toggle("on", !!s.running);
|
|
727
758
|
$("conn").title = s.running ? "Claude Code is running" : "Claude Code starts with the next message";
|
|
759
|
+
const caps = s.capabilities || {};
|
|
760
|
+
const why = (c) => (c && c.available === false ? c.reason : "");
|
|
728
761
|
$("add-shot").disabled = !s.chrome;
|
|
729
|
-
$("add-shot").title = s.chrome ? "" : "Screenshots need Chrome or Chromium";
|
|
762
|
+
$("add-shot").title = s.chrome ? "" : why(caps.screenshot) || "Screenshots need Chrome or Chromium";
|
|
763
|
+
for (const item of $("export-menu").querySelectorAll("[data-export]")) {
|
|
764
|
+
item.disabled = !!why(caps.pdfExport);
|
|
765
|
+
item.title = why(caps.pdfExport);
|
|
766
|
+
}
|
|
767
|
+
const imp = $("add-slide-menu").querySelector('[data-slide="import"]');
|
|
768
|
+
imp.disabled = !!why(caps.pptxImport);
|
|
769
|
+
imp.title = why(caps.pptxImport);
|
|
770
|
+
if (why(caps.claude)) $("conn").title = caps.claude.reason;
|
|
771
|
+
// where the server runs, when it is not this Mac's usual setup: "Linux (beta)", with what is off
|
|
772
|
+
const names = { claude: "Claude", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
|
|
773
|
+
pdfExport: "PDF export", pdfPages: "Pictures of PDF pages", pptxImport: "PowerPoint import",
|
|
774
|
+
pptxPictures: "Pictures of PowerPoint slides", audioConvert: "Waveforms of non-WAV audio" };
|
|
775
|
+
const off = Object.entries(caps).filter(([, c]) => c && c.available === false).map(([k]) => "• " + (names[k] || k));
|
|
776
|
+
const reasons = [...new Set(Object.values(caps).filter((c) => c && c.available === false).map((c) => c.reason))];
|
|
777
|
+
$("server-note").hidden = !caps.platform || caps.platform === "darwin";
|
|
778
|
+
$("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
|
|
779
|
+
$("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
|
|
730
780
|
$("add-slide-btn").disabled = !!s.busy;
|
|
781
|
+
// the deck's slide tags stopped working while PinDeck runs (at start, such a deck is refused)
|
|
782
|
+
if (s.deckProblem && s.deckProblem !== shownProblem) toast(s.deckProblem, { ms: 20000 });
|
|
783
|
+
shownProblem = s.deckProblem;
|
|
731
784
|
renderToolbar();
|
|
732
785
|
}
|
|
733
786
|
function connect() {
|
|
734
|
-
const es = new EventSource(
|
|
787
|
+
const es = new EventSource("/api/events");
|
|
735
788
|
es.addEventListener("hello", (ev) => {
|
|
736
789
|
const d = JSON.parse(ev.data);
|
|
790
|
+
// reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
|
|
791
|
+
if (state.rev && d.rev && d.rev !== state.rev && main) { loadMain(); loadMeasure(); }
|
|
792
|
+
if (d.rev) state.rev = d.rev;
|
|
737
793
|
clearChat();
|
|
738
794
|
state.turns = new Map(d.turns.map((t) => [t.id, t]));
|
|
739
795
|
d.chat.forEach(renderEntry);
|
|
@@ -757,7 +813,8 @@ function connect() {
|
|
|
757
813
|
rerenderTurns();
|
|
758
814
|
});
|
|
759
815
|
es.addEventListener("files", (ev) => {
|
|
760
|
-
const { files, manual } = JSON.parse(ev.data);
|
|
816
|
+
const { files, manual, rev } = JSON.parse(ev.data);
|
|
817
|
+
if (rev) state.rev = rev;
|
|
761
818
|
const cssOnly = files.every((f) => f.endsWith(".css"));
|
|
762
819
|
if (manual && main) {
|
|
763
820
|
loadMeasure(); // the visible slide already shows the hand edit
|
|
@@ -774,7 +831,8 @@ function connect() {
|
|
|
774
831
|
try { api("measure-result", { id, result: await queueMeasure(index) }); }
|
|
775
832
|
catch (err) { api("measure-result", { id, error: String(err) }); }
|
|
776
833
|
});
|
|
777
|
-
// EventSource reconnects by itself; if the server restarted,
|
|
834
|
+
// EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
|
|
835
|
+
// which asks for a new login link
|
|
778
836
|
es.onerror = async () => {
|
|
779
837
|
try { if ((await api("ping")).status === 403) location.reload(); } catch {}
|
|
780
838
|
};
|
|
@@ -816,7 +874,7 @@ $("import-file").addEventListener("change", async () => {
|
|
|
816
874
|
if (!file) return;
|
|
817
875
|
toast(`Reading ${file.name}…`);
|
|
818
876
|
const res = await fetch("/api/import", { method: "POST", body: file,
|
|
819
|
-
headers: { "x-
|
|
877
|
+
headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
|
|
820
878
|
const d = await res.json().catch(() => ({}));
|
|
821
879
|
if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
|
|
822
880
|
importing = d;
|
|
@@ -859,7 +917,7 @@ function showImportThumbs(ev) {
|
|
|
859
917
|
for (const c of importChecks()) {
|
|
860
918
|
const img = new Image();
|
|
861
919
|
img.alt = "";
|
|
862
|
-
img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}
|
|
920
|
+
img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}`;
|
|
863
921
|
c.closest(".thumb").append(img);
|
|
864
922
|
}
|
|
865
923
|
}
|
|
@@ -976,7 +1034,7 @@ const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Ma
|
|
|
976
1034
|
|
|
977
1035
|
async function uploadBlob(blob, name) {
|
|
978
1036
|
const res = await fetch("/api/upload", { method: "POST", body: blob,
|
|
979
|
-
headers: { "x-
|
|
1037
|
+
headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
|
|
980
1038
|
const d = await res.json().catch(() => ({}));
|
|
981
1039
|
if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
|
|
982
1040
|
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>
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
double-click to change the text. A selected element is what Claude takes “this” to mean.</p>
|
|
96
96
|
<p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; Claude can put it on a slide
|
|
97
97
|
or, for a paper or report, take the details from it.</p>
|
|
98
|
-
<p>Claude edits <code>index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
|
|
98
|
+
<p>Claude edits <code id="deck-file">index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
|
|
99
99
|
<code>brand.css</code> is locked.</p>
|
|
100
100
|
</div>
|
|
101
101
|
</div>
|