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/README.md +96 -11
- package/cli.mjs +4 -2
- package/lib/capabilities.mjs +112 -0
- package/lib/claude.mjs +8 -3
- package/lib/deck-files.mjs +56 -0
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/package.json +6 -3
- package/public/editor.css +1 -0
- package/public/editor.js +65 -15
- package/public/index.html +1 -1
- package/server.mjs +445 -116
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 = {
|
|
@@ -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)
|
|
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(
|
|
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(
|
|
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,
|
|
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-
|
|
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}
|
|
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-
|
|
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>
|