pindeck 3.0.0-beta.3 → 3.2.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/README.md +136 -38
- package/cli.mjs +19 -9
- package/front.mjs +413 -0
- package/lib/agent-process.mjs +4 -1
- package/lib/agent-tools.mjs +5 -2
- package/lib/asset-roots.mjs +22 -0
- package/lib/claude.mjs +10 -2
- package/lib/deck-files.mjs +5 -3
- package/lib/deck-list.mjs +72 -0
- package/lib/deck-target.mjs +43 -0
- package/lib/eids.mjs +15 -6
- package/lib/login.mjs +75 -0
- package/lib/models.mjs +10 -4
- package/lib/pdf.mjs +18 -2
- package/lib/servers.mjs +91 -0
- package/lib/usage.mjs +15 -0
- package/package.json +4 -3
- package/public/bridge.js +56 -7
- package/public/editor.css +30 -3
- package/public/editor.js +260 -99
- package/public/events-worker.js +47 -0
- package/public/front.css +14 -0
- package/public/index.html +10 -2
- package/server.mjs +493 -235
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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("
|
|
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(
|
|
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
|
-
//
|
|
35
|
-
function deckSrc(mode, extra = "") { return
|
|
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");
|
|
@@ -42,10 +47,22 @@ function loadMain() {
|
|
|
42
47
|
$("stage").appendChild(f);
|
|
43
48
|
if (pendingMain) pendingMain.remove();
|
|
44
49
|
pendingMain = f;
|
|
50
|
+
// a deck page whose slides never start (a script that fails, broken HTML, the file gone): say so
|
|
51
|
+
setTimeout(() => { if (pendingMain === f) stageProblem("The deck page did not start within 15 seconds."); }, 15000);
|
|
45
52
|
}
|
|
53
|
+
/* Why the stage stays empty. Messages still go to the assistant, which can be asked to fix the deck. */
|
|
54
|
+
function stageProblem(why) {
|
|
55
|
+
let el = $("stage-problem");
|
|
56
|
+
if (!why) return el?.remove();
|
|
57
|
+
if (!el) { el = document.createElement("div"); el.id = "stage-problem"; el.className = "stage-problem"; $("stage").appendChild(el); }
|
|
58
|
+
el.innerHTML = `<b>PinDeck cannot show this deck.</b><span>${esc(why)}</span>` +
|
|
59
|
+
`<span>Fix the file, or describe the problem in the chat and the assistant can fix it.</span>`;
|
|
60
|
+
}
|
|
61
|
+
let measureReject = null;
|
|
46
62
|
function loadMeasure() {
|
|
47
63
|
measure?.iframe.remove();
|
|
48
|
-
measureReady = new Promise((
|
|
64
|
+
measureReady = new Promise((resolve, reject) => { measureResolve = resolve; measureReject = reject; });
|
|
65
|
+
measureReady.catch(() => {});
|
|
49
66
|
const f = document.createElement("iframe");
|
|
50
67
|
f.className = "measure";
|
|
51
68
|
f.setAttribute("aria-hidden", "true");
|
|
@@ -55,19 +72,52 @@ function loadMeasure() {
|
|
|
55
72
|
$("stage").appendChild(f);
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
/* The slide frames run the deck sandboxed, in an origin of their own (see VIEW in server.mjs): the editor and
|
|
76
|
+
the bridge in each frame talk by messages. A frame's bridge is a proxy whose calls answer with promises;
|
|
77
|
+
the bridge calls the methods of editorHost below. Only the editor's own frames are listened to, and a deck's
|
|
78
|
+
own script, which shares its frame with the bridge, can do no more than the bridge: hand edits to its deck. */
|
|
79
|
+
let callSeq = 0;
|
|
80
|
+
const answers = new Map(); // id -> resolve
|
|
81
|
+
function bridgeOf(win) {
|
|
82
|
+
// not a thenable: a promise resolved with the proxy must not call it
|
|
83
|
+
return new Proxy({}, { get: (_, method) => method === "then" ? undefined : (...args) => new Promise((resolve) => {
|
|
84
|
+
const id = ++callSeq;
|
|
85
|
+
answers.set(id, resolve);
|
|
86
|
+
win.postMessage({ pindeck: "bridge", id, method, args }, "*");
|
|
87
|
+
}) });
|
|
88
|
+
}
|
|
89
|
+
addEventListener("message", async (e) => {
|
|
90
|
+
const m = e.data;
|
|
91
|
+
if (!m || typeof m !== "object" || !e.source) return;
|
|
92
|
+
const from = [main?.iframe, pendingMain, measure?.iframe].find((f) => f?.contentWindow === e.source);
|
|
93
|
+
if (!from) return;
|
|
94
|
+
if (m.pindeck === "bridge-answer") { answers.get(m.id)?.(m.value); answers.delete(m.id); return; }
|
|
95
|
+
if (m.pindeck !== "host" || !Object.hasOwn(editorHost, m.method)) return;
|
|
96
|
+
// only a frame that is showing may edit; a frame still loading may only say that it is ready, or why not
|
|
97
|
+
if (m.method !== "bridgeReady" && m.method !== "deckFailed" && from !== main?.iframe) return;
|
|
98
|
+
let value = null;
|
|
99
|
+
try { value = await editorHost[m.method](...(Array.isArray(m.args) ? m.args : []), e.source); } catch (err) { console.error(err); }
|
|
100
|
+
e.source.postMessage({ pindeck: "host-answer", id: m.id, value: value ?? null }, "*");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const editorHost = {
|
|
104
|
+
deckFailed(role, why, win) {
|
|
105
|
+
if (role === "measure") { if (measure?.iframe.contentWindow === win) measureReject?.(new Error(why)); return; }
|
|
106
|
+
if (pendingMain?.contentWindow === win || main?.iframe.contentWindow === win) stageProblem(why);
|
|
107
|
+
},
|
|
108
|
+
bridgeReady(role, inf, win) {
|
|
60
109
|
if (role === "measure") {
|
|
61
|
-
if (measure && measure.iframe.contentWindow === win) { measure.bridge =
|
|
110
|
+
if (measure && measure.iframe.contentWindow === win) { measure.bridge = bridgeOf(win); measureResolve(measure.bridge); }
|
|
62
111
|
return;
|
|
63
112
|
}
|
|
64
113
|
if (!pendingMain || pendingMain.contentWindow !== win) return;
|
|
65
114
|
const old = main;
|
|
115
|
+
const bridge = bridgeOf(win);
|
|
66
116
|
main = { iframe: pendingMain, bridge };
|
|
67
117
|
pendingMain = null;
|
|
68
118
|
main.iframe.classList.remove("loading");
|
|
69
119
|
old?.iframe.remove();
|
|
70
|
-
|
|
120
|
+
stageProblem(null);
|
|
71
121
|
state.slides = inf.slides;
|
|
72
122
|
state.current = Math.min(state.current, state.slides.length - 1);
|
|
73
123
|
if (inf.current !== state.current) bridge.goto(state.current);
|
|
@@ -83,7 +133,7 @@ window.__editorHost = {
|
|
|
83
133
|
},
|
|
84
134
|
slideChanged(index) {
|
|
85
135
|
state.current = index;
|
|
86
|
-
try { localStorage.setItem(
|
|
136
|
+
try { localStorage.setItem(SLIDE_KEY, String(index)); } catch {}
|
|
87
137
|
renderSlides();
|
|
88
138
|
renderToolbar();
|
|
89
139
|
loadNotes();
|
|
@@ -120,13 +170,20 @@ window.__editorHost = {
|
|
|
120
170
|
loadMain(); // re-render: math, footer numbers, and the file as saved
|
|
121
171
|
}, "Could not save the text.");
|
|
122
172
|
},
|
|
123
|
-
commitSplit(op
|
|
124
|
-
|
|
125
|
-
|
|
173
|
+
commitSplit(op) { // answers with the new bullet's id as saved (null if it was not)
|
|
174
|
+
let eid = null;
|
|
175
|
+
return queueEdit(async () => {
|
|
176
|
+
const { whole, ...req } = op;
|
|
177
|
+
const res = await api("split", req);
|
|
126
178
|
const d = await res.json().catch(() => ({}));
|
|
127
|
-
if (!res.ok) {
|
|
128
|
-
|
|
129
|
-
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
if (d.code === "stale") keepRejectedText(whole ?? op.text, d);
|
|
181
|
+
else toast(d.error || "Could not add the bullet.");
|
|
182
|
+
loadMain();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
eid = d.eid;
|
|
186
|
+
}, "Could not add the bullet.").then(() => eid);
|
|
130
187
|
},
|
|
131
188
|
commitRemove(eid) {
|
|
132
189
|
queueEdit(async () => {
|
|
@@ -136,7 +193,11 @@ window.__editorHost = {
|
|
|
136
193
|
}, "Could not remove the bullet.");
|
|
137
194
|
},
|
|
138
195
|
refresh() { queueEdit(() => loadMain(), "Could not reload the slide."); },
|
|
139
|
-
editingChanged(on) {
|
|
196
|
+
editingChanged(on) {
|
|
197
|
+
state.editing = on;
|
|
198
|
+
// after the edit's own save, which the bridge asks for next
|
|
199
|
+
if (!on && heldFiles) setTimeout(() => queueEdit(() => { const d = heldFiles; heldFiles = null; if (d) applyFiles(d); }), 0);
|
|
200
|
+
},
|
|
140
201
|
requestDelete(eid) { requestDelete(eid); },
|
|
141
202
|
dropFiles(files, at) { attachFiles(files, at); },
|
|
142
203
|
dragOver(on) { $("drop-hint").hidden = !on; },
|
|
@@ -206,9 +267,11 @@ function toast(msg, { html = false, ms = 3200 } = {}) {
|
|
|
206
267
|
toastTimer = setTimeout(() => ($("toast").hidden = true), ms);
|
|
207
268
|
}
|
|
208
269
|
|
|
270
|
+
// a deck page that never starts, or stops answering, must not hold up a message: give up after a while
|
|
271
|
+
const within = (ms, p, what) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} timed out`)), ms))]);
|
|
209
272
|
async function measureSlide(index) {
|
|
210
|
-
const b = await measureReady;
|
|
211
|
-
return b.measure(index);
|
|
273
|
+
const b = await within(8000, measureReady, "The measuring frame");
|
|
274
|
+
return within(8000, b.measure(index), "Measuring the slide");
|
|
212
275
|
}
|
|
213
276
|
// one measurement at a time: the measure frame has a single "current slide"
|
|
214
277
|
let measureChain = Promise.resolve();
|
|
@@ -297,7 +360,7 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
|
|
|
297
360
|
if (!res.ok) return toast(d.error || "Could not move the slide.");
|
|
298
361
|
if (d.unchanged) return;
|
|
299
362
|
state.current = d.index; // the reload opens the moved slide
|
|
300
|
-
try { localStorage.setItem(
|
|
363
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
301
364
|
loadMain();
|
|
302
365
|
}
|
|
303
366
|
|
|
@@ -366,7 +429,7 @@ notesBox.addEventListener("keydown", (e) => {
|
|
|
366
429
|
// closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
|
|
367
430
|
addEventListener("pagehide", () => {
|
|
368
431
|
if (notesDirty() && !state.busy)
|
|
369
|
-
navigator.sendBeacon("
|
|
432
|
+
navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
|
|
370
433
|
});
|
|
371
434
|
function setNotesOpen(open) {
|
|
372
435
|
$("notes-panel").classList.toggle("collapsed", !open);
|
|
@@ -465,6 +528,31 @@ function wireMenu(btnId, menuId, beforeOpen) {
|
|
|
465
528
|
const closeMenus = () => setMenu(null, null, false);
|
|
466
529
|
document.addEventListener("click", (e) => { if (!e.target.closest(".menu")) closeMenus(); });
|
|
467
530
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }, true);
|
|
531
|
+
/* The deck menu, behind the front only: the decks in the deck's folder and the asset roots (and one folder
|
|
532
|
+
down). Each is a link, so a deck opens in place, or in a new tab with ⌘-click; the deck left behind keeps
|
|
533
|
+
its chat and undo, and the assistant goes on with a turn it has begun. */
|
|
534
|
+
if (FRONT) {
|
|
535
|
+
$("deck-btn").disabled = false;
|
|
536
|
+
$("deck-btn").title = "Open another deck";
|
|
537
|
+
$("deck-btn").querySelector(".caret").hidden = false;
|
|
538
|
+
wireMenu("deck-btn", "deck-menu", async () => {
|
|
539
|
+
const menu = $("deck-menu");
|
|
540
|
+
menu.innerHTML = '<div class="menu-note">Looking for decks…</div>';
|
|
541
|
+
let decks = [];
|
|
542
|
+
try { decks = (await (await fetch("/api/decks")).json()).decks; }
|
|
543
|
+
catch { menu.innerHTML = '<div class="menu-note">The deck list is not available.</div>'; return; }
|
|
544
|
+
const roots = [...new Set(decks.map((d) => d.root))];
|
|
545
|
+
menu.innerHTML = roots.map((root) => (roots.length > 1 ? `<div class="group" title="${esc(root)}">${esc(root.split("/").pop())}</div>` : "") +
|
|
546
|
+
decks.filter((d) => d.root === root).map((d) => {
|
|
547
|
+
const here = BASE === `/d/${d.key}/`;
|
|
548
|
+
return d.problem
|
|
549
|
+
? `<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>`
|
|
550
|
+
: `<a class="opt" role="menuitem" href="/d/${esc(d.key)}/" ${here ? 'aria-current="page"' : ""}><span class="tick">${here ? "✓" : ""}</span>` +
|
|
551
|
+
`<b>${esc(d.rel)}</b>${here ? "" : d.open ? '<span class="d">open</span>' : ""}</a>`;
|
|
552
|
+
}).join("")).join("") +
|
|
553
|
+
'<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>';
|
|
554
|
+
});
|
|
555
|
+
}
|
|
468
556
|
const setExportMenu = (open) => setMenu($("export-btn"), $("export-menu"), open);
|
|
469
557
|
wireMenu("export-btn", "export-menu");
|
|
470
558
|
$("export-menu").addEventListener("click", async (e) => {
|
|
@@ -493,7 +581,7 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
493
581
|
});
|
|
494
582
|
|
|
495
583
|
$("present").onclick = () => {
|
|
496
|
-
window.open(
|
|
584
|
+
window.open(`${BASE}deck/${state.slides[state.current]?.hash || ""}`, "_blank");
|
|
497
585
|
};
|
|
498
586
|
|
|
499
587
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -515,7 +603,6 @@ function pinsChanged() {
|
|
|
515
603
|
main?.bridge.renderPins(state.pins);
|
|
516
604
|
renderPinTray();
|
|
517
605
|
renderSlides();
|
|
518
|
-
api("pins", { pins: state.pins });
|
|
519
606
|
}
|
|
520
607
|
function shortEid(p) {
|
|
521
608
|
if (p.tag?.startsWith("footer")) return "footer";
|
|
@@ -535,7 +622,7 @@ function renderPinTray() {
|
|
|
535
622
|
}
|
|
536
623
|
$("pin-tray").addEventListener("click", (e) => {
|
|
537
624
|
const x = e.target.closest("[data-x]");
|
|
538
|
-
if (x) return
|
|
625
|
+
if (x) return editorHost.removePin(+x.dataset.x);
|
|
539
626
|
if (e.target.closest("[data-unsel]")) return main?.bridge.deselect();
|
|
540
627
|
if (e.target.closest("[data-sel]")) return main?.bridge.highlight(state.sel.eid);
|
|
541
628
|
const chip = e.target.closest(".chip");
|
|
@@ -571,34 +658,41 @@ function md(s) {
|
|
|
571
658
|
}).join("");
|
|
572
659
|
return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
|
|
573
660
|
}
|
|
661
|
+
/* A screenshot in the chat: a small picture that opens large. Only the newest ones are kept on disk;
|
|
662
|
+
an older one says so (see the error listener below). */
|
|
663
|
+
const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
|
|
664
|
+
`<img src="${BASE}shots/${encodeURIComponent(file)}" alt="" loading="lazy">${label ? ` ${esc(label)}` : ""}</button>`;
|
|
665
|
+
// only the most recent change still in effect can be undone: it alone gets an Undo button
|
|
666
|
+
const lastLive = () => state.status?.undoId;
|
|
574
667
|
function entryHtml(e) {
|
|
575
668
|
switch (e.kind) {
|
|
576
669
|
case "user":
|
|
577
670
|
return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
|
|
578
671
|
`<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
672
|
`<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="
|
|
673
|
+
e.screenshot?.file ? shotChip(e.screenshot.file, `screenshot S${e.screenshot.slide}`) : e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
|
|
674
|
+
`<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
675
|
case "assistant": return md(e.text);
|
|
583
676
|
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>`;
|
|
677
|
+
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
678
|
case "edit": {
|
|
586
|
-
const t = state.turns.get(e.turnId);
|
|
587
|
-
return `<span class="files ${
|
|
588
|
-
|
|
589
|
-
${t && !t.undone ? `<button class="small" data-undo="${e.turnId}">Undo</button>` :
|
|
679
|
+
const t = state.turns.get(e.turnId), undone = t ? t.undone : e.undone;
|
|
680
|
+
return `<span class="files ${undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
|
|
681
|
+
${t ? `<button class="small" data-diff="${e.turnId}">Diff</button>` : ""}
|
|
682
|
+
${t && !t.undone && t.id === lastLive() ? `<button class="small" data-undo="${e.turnId}">Undo</button>` : undone ? "<span>undone</span>" : ""}`;
|
|
590
683
|
}
|
|
591
684
|
case "turn": {
|
|
592
|
-
|
|
685
|
+
// a change from before PinDeck restarted: its undo history (and diff) ended with that run
|
|
686
|
+
const t = e.turnId ? state.turns.get(e.turnId) : null, undone = t ? t.undone : e.undone;
|
|
593
687
|
const parts = [];
|
|
594
688
|
if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
|
|
595
689
|
if (e.files?.length) {
|
|
596
|
-
parts.push(`<span class="files ${
|
|
690
|
+
parts.push(`<span class="files ${undone ? "undone" : ""}" ${t ? "" : 'title="From before PinDeck restarted: no diff or undo any more"'}>Changed ${e.files.map((f) =>
|
|
597
691
|
`${esc(f.file)} ` + (f.binary ? `<span class="add">(binary)</span>`
|
|
598
692
|
: `<span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`)).join(", ")}</span>`);
|
|
599
|
-
parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
|
|
600
|
-
if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
|
|
601
|
-
if (
|
|
693
|
+
if (t) parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
|
|
694
|
+
if (t && !t.undone && t.id === lastLive()) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
|
|
695
|
+
if (undone) parts.push("<span>undone</span>");
|
|
602
696
|
} else if (!e.error) parts.push("No file changes");
|
|
603
697
|
if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
|
|
604
698
|
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">` +
|
|
@@ -629,7 +723,23 @@ function clearChat() {
|
|
|
629
723
|
$("chat-empty").hidden = false;
|
|
630
724
|
}
|
|
631
725
|
|
|
726
|
+
// a picture that is no longer kept: the chip says so and opens nothing (errors do not bubble: capture)
|
|
727
|
+
chatEl.addEventListener("error", (e) => {
|
|
728
|
+
const chip = e.target.closest?.("[data-shot]");
|
|
729
|
+
if (!chip || e.target.tagName !== "IMG") return;
|
|
730
|
+
chip.removeAttribute("data-shot");
|
|
731
|
+
chip.classList.add("gone");
|
|
732
|
+
chip.title = "Only the newest screenshots are kept";
|
|
733
|
+
chip.textContent = "📷 no longer kept";
|
|
734
|
+
}, true);
|
|
632
735
|
chatEl.addEventListener("click", async (e) => {
|
|
736
|
+
const s = e.target.closest("[data-shot]");
|
|
737
|
+
if (s) {
|
|
738
|
+
$("shot-img").src = `${BASE}shots/${encodeURIComponent(s.dataset.shot)}`;
|
|
739
|
+
$("shot-title").textContent = s.dataset.shot;
|
|
740
|
+
$("shot-dialog").showModal();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
633
743
|
const d = e.target.closest("[data-diff]");
|
|
634
744
|
if (d) {
|
|
635
745
|
const res = await api(`turn/${d.dataset.diff}/diff`);
|
|
@@ -651,6 +761,9 @@ chatEl.addEventListener("click", async (e) => {
|
|
|
651
761
|
}
|
|
652
762
|
});
|
|
653
763
|
$("diff-close").onclick = () => $("diff-dialog").close();
|
|
764
|
+
$("shot-close").onclick = () => $("shot-dialog").close();
|
|
765
|
+
// Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
|
|
766
|
+
$("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
|
|
654
767
|
|
|
655
768
|
function setBusy(busy) {
|
|
656
769
|
state.busy = busy;
|
|
@@ -681,7 +794,15 @@ async function send() {
|
|
|
681
794
|
({ path, kind, name, bytes, width, height, duration, sampleRate, channels, waveform, pages, pageSize, droppedAt })),
|
|
682
795
|
current: s ? { index: state.current, eid: s.eid } : null,
|
|
683
796
|
});
|
|
684
|
-
if (!res.ok) {
|
|
797
|
+
if (!res.ok) {
|
|
798
|
+
const raw = await res.text().catch(() => "");
|
|
799
|
+
let d = null;
|
|
800
|
+
try { d = JSON.parse(raw); } catch {}
|
|
801
|
+
const why = d?.error || (/too large/i.test(raw) ? "The message is too large to send (the limit is 5 MB)." : `Could not send the message (${res.status}).`);
|
|
802
|
+
// a missing backend needs the user's attention; the rest is said in passing, the message stays in the box
|
|
803
|
+
if (d?.code?.endsWith("-unavailable")) alert(why); else toast(why, { ms: 8000 });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
685
806
|
$("input").value = "";
|
|
686
807
|
autosize();
|
|
687
808
|
state.shot = null;
|
|
@@ -694,9 +815,9 @@ async function send() {
|
|
|
694
815
|
}
|
|
695
816
|
}
|
|
696
817
|
$("send").onclick = send;
|
|
697
|
-
$("stop").onclick = () => api("stop", {});
|
|
818
|
+
$("stop").onclick = () => { $("working-text").textContent = "Stopping…"; api("stop", {}); };
|
|
698
819
|
$("new-chat").onclick = async () => {
|
|
699
|
-
if (!confirm("Start a new conversation? The assistant forgets this chat; your slides stay as they are.")) return;
|
|
820
|
+
if (!confirm("Start a new conversation? The assistant forgets this chat; your slides, and Undo and Redo, stay as they are.")) return;
|
|
700
821
|
await api("new-chat", {});
|
|
701
822
|
};
|
|
702
823
|
function autosize() {
|
|
@@ -718,7 +839,10 @@ function handleKey(k, mod = {}) {
|
|
|
718
839
|
else if (k === "v" || k === "V") setMode("select");
|
|
719
840
|
else if (k === "h" || k === "H") toggleHidden();
|
|
720
841
|
else if (k === "Escape") { if (state.mode === "pin") setMode("select"); else main?.bridge.deselect(); }
|
|
721
|
-
else if (k === "Enter") {
|
|
842
|
+
else if (k === "Enter") {
|
|
843
|
+
if (!(state.sel && state.mode === "select" && main)) $("input").focus();
|
|
844
|
+
else main.bridge.editSelection().then((editing) => { if (!editing) $("input").focus(); });
|
|
845
|
+
}
|
|
722
846
|
else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete(state.sel.eid);
|
|
723
847
|
else if (k.startsWith("Arrow") && state.sel && state.mode === "select") {
|
|
724
848
|
const step = mod.shift ? 10 : 1;
|
|
@@ -729,6 +853,10 @@ function handleKey(k, mod = {}) {
|
|
|
729
853
|
}
|
|
730
854
|
document.addEventListener("keydown", (e) => {
|
|
731
855
|
if (e.target.closest?.("input, textarea, dialog")) return;
|
|
856
|
+
// a menu and the button that opens it keep Enter, Space and the arrow keys (a menu opens, an item is chosen):
|
|
857
|
+
// no slide change, no jump to the chat. Other buttons do not: after a click on ‹ › the focus stays there
|
|
858
|
+
// while the slide is clicked (the slide frame keeps it), and Enter must still edit the selected text.
|
|
859
|
+
if (["Enter", " ", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key) && e.target.closest?.(".menu, [aria-haspopup]")) return;
|
|
732
860
|
const meta = e.metaKey || e.ctrlKey;
|
|
733
861
|
if (meta && (e.key === "z" || e.key === "Z")) { e.preventDefault(); return handleKey(e.key, { meta, shift: e.shiftKey }); }
|
|
734
862
|
if (meta || e.altKey) return;
|
|
@@ -741,13 +869,16 @@ document.addEventListener("keydown", (e) => {
|
|
|
741
869
|
/* ---- Server events ---------------------------------------------------------------------------------- */
|
|
742
870
|
let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
|
|
743
871
|
function applyStatus(s) {
|
|
872
|
+
if (!s.models) s.models = state.status?.models; // sent only when they change
|
|
873
|
+
const undoMoved = s.undoId !== state.status?.undoId;
|
|
744
874
|
state.status = s;
|
|
875
|
+
if (undoMoved) rerenderTurns(); // the Undo button moves to the change that is now the most recent
|
|
745
876
|
// the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
|
|
746
877
|
if (s.deck) {
|
|
747
878
|
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
748
879
|
const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
|
|
749
880
|
$("deck-name").textContent = name;
|
|
750
|
-
$("deck-
|
|
881
|
+
$("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
|
|
751
882
|
if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
|
|
752
883
|
document.title = `${name} · PinDeck`;
|
|
753
884
|
}
|
|
@@ -758,7 +889,7 @@ function applyStatus(s) {
|
|
|
758
889
|
renderEffortMenu();
|
|
759
890
|
$("new-chat").disabled = !!s.busy;
|
|
760
891
|
$("stop").hidden = !s.busy || !!s.switching;
|
|
761
|
-
$("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
|
|
892
|
+
$("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : "The assistant is working…";
|
|
762
893
|
$("conn").classList.toggle("on", !!s.running);
|
|
763
894
|
$("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
|
|
764
895
|
// only the selected backend's availability matters: Codex missing is no news to someone using Claude
|
|
@@ -791,59 +922,89 @@ function applyStatus(s) {
|
|
|
791
922
|
shownProblem = s.deckProblem;
|
|
792
923
|
renderToolbar();
|
|
793
924
|
}
|
|
925
|
+
let heldFiles = null; // file changes that came while a text edit was open
|
|
926
|
+
function applyFiles({ files, manual, rev }) {
|
|
927
|
+
if (rev) state.rev = rev;
|
|
928
|
+
const cssOnly = files.every((f) => f.endsWith(".css"));
|
|
929
|
+
if (manual && main) {
|
|
930
|
+
loadMeasure(); // the visible slide already shows the hand edit
|
|
931
|
+
} else if (cssOnly && main) {
|
|
932
|
+
main.bridge.reloadCss(files);
|
|
933
|
+
measure?.bridge?.reloadCss(files);
|
|
934
|
+
} else {
|
|
935
|
+
loadMain();
|
|
936
|
+
loadMeasure();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/* The deck's events: behind the front through the worker all of this browser's tabs share (browsers open at
|
|
940
|
+
most 6 connections to one port; see public/events-worker.js), on its own server straight from it */
|
|
941
|
+
function openEvents(on, onError) {
|
|
942
|
+
if (FRONT && typeof SharedWorker === "function") {
|
|
943
|
+
const deck = BASE.split("/")[2];
|
|
944
|
+
const w = new SharedWorker("/editor/events-worker.js", { name: "pindeck-events" });
|
|
945
|
+
w.port.onmessage = ({ data: m }) => (m.type === "error" ? onError() : on[m.type]?.(m.data));
|
|
946
|
+
w.port.start();
|
|
947
|
+
w.port.postMessage({ subscribe: deck });
|
|
948
|
+
addEventListener("pagehide", () => w.port.postMessage({ close: true }));
|
|
949
|
+
addEventListener("pageshow", (e) => { if (e.persisted) w.port.postMessage({ subscribe: deck }); });
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const es = new EventSource(BASE + "api/events");
|
|
953
|
+
for (const [type, fn] of Object.entries(on)) es.addEventListener(type, (ev) => fn(JSON.parse(ev.data)));
|
|
954
|
+
es.onerror = onError;
|
|
955
|
+
}
|
|
794
956
|
function connect() {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
});
|
|
957
|
+
openEvents({
|
|
958
|
+
hello(d) {
|
|
959
|
+
// reconnected after missing file events (tunnel dropped, laptop asleep, deck server restarted): show the
|
|
960
|
+
// files as they are now
|
|
961
|
+
if (state.rev && d.rev && d.rev !== state.rev && main) { loadMain(); loadMeasure(); }
|
|
962
|
+
if (d.rev) state.rev = d.rev;
|
|
963
|
+
clearChat();
|
|
964
|
+
state.turns = new Map(d.turns.map((t) => [t.id, t]));
|
|
965
|
+
d.chat.forEach(renderEntry);
|
|
966
|
+
applyStatus(d.status);
|
|
967
|
+
scrollChat();
|
|
968
|
+
},
|
|
969
|
+
chat(e) {
|
|
970
|
+
const change = (e.kind === "turn" || e.kind === "edit") && e.turnId;
|
|
971
|
+
if (change) state.turns.set(e.turnId, { id: e.turnId, undone: false });
|
|
972
|
+
renderEntry(e);
|
|
973
|
+
scrollChat();
|
|
974
|
+
},
|
|
975
|
+
"chat-update": (e) => renderEntry(e),
|
|
976
|
+
import: (d) => showImportThumbs(d),
|
|
977
|
+
status: (s) => applyStatus(s),
|
|
978
|
+
reset() { clearChat(); state.turns.clear(); },
|
|
979
|
+
undone(d) {
|
|
980
|
+
const t = state.turns.get(d.turnId);
|
|
981
|
+
if (t) t.undone = d.undone !== false;
|
|
982
|
+
rerenderTurns();
|
|
983
|
+
},
|
|
984
|
+
files(d) {
|
|
985
|
+
// typing in the slide: the reload waits until the edit ends, and the edit is saved against the files
|
|
986
|
+
// it began with (changed meanwhile: refused, with the text to copy; see keepRejectedText)
|
|
987
|
+
if (state.editing) {
|
|
988
|
+
heldFiles = heldFiles ? { files: [...new Set([...heldFiles.files, ...d.files])], manual: heldFiles.manual && d.manual, rev: d.rev } : d;
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
applyFiles(d);
|
|
992
|
+
},
|
|
993
|
+
async measure({ id, index }) {
|
|
994
|
+
try { api("measure-result", { id, result: await queueMeasure(index) }); }
|
|
995
|
+
catch (err) { api("measure-result", { id, error: String(err) }); }
|
|
996
|
+
},
|
|
997
|
+
// the deck server stopped: the front opens it again and its "hello" brings the state as it is then
|
|
998
|
+
gone(d) {
|
|
999
|
+
$("conn").classList.remove("on");
|
|
1000
|
+
$("conn").title = d?.error || "The deck's server stopped; reconnecting…";
|
|
1001
|
+
if (d?.error) toast(d.error, { ms: 20000 });
|
|
1002
|
+
},
|
|
842
1003
|
// EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
|
|
843
1004
|
// which asks for a new login link
|
|
844
|
-
|
|
1005
|
+
}, async () => {
|
|
845
1006
|
try { if ((await api("ping")).status === 403) location.reload(); } catch {}
|
|
846
|
-
};
|
|
1007
|
+
});
|
|
847
1008
|
}
|
|
848
1009
|
|
|
849
1010
|
/* ---- New slides --------------------------------------------------------------------------------------
|
|
@@ -868,7 +1029,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
|
|
|
868
1029
|
if (!res.ok) return toast(d.error || "Could not add the slide.");
|
|
869
1030
|
state.current = d.index; // the reload opens the new slide ...
|
|
870
1031
|
state.pendingTitleEdit = item.dataset.slide !== "duplicate"; // ... with its title ready for typing
|
|
871
|
-
try { localStorage.setItem(
|
|
1032
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
872
1033
|
loadMain();
|
|
873
1034
|
});
|
|
874
1035
|
|
|
@@ -881,7 +1042,7 @@ $("import-file").addEventListener("change", async () => {
|
|
|
881
1042
|
$("import-file").value = "";
|
|
882
1043
|
if (!file) return;
|
|
883
1044
|
toast(`Reading ${file.name}…`);
|
|
884
|
-
const res = await fetch("
|
|
1045
|
+
const res = await fetch(BASE + "api/import", { method: "POST", body: file,
|
|
885
1046
|
headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
|
|
886
1047
|
const d = await res.json().catch(() => ({}));
|
|
887
1048
|
if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
|
|
@@ -925,7 +1086,7 @@ function showImportThumbs(ev) {
|
|
|
925
1086
|
for (const c of importChecks()) {
|
|
926
1087
|
const img = new Image();
|
|
927
1088
|
img.alt = "";
|
|
928
|
-
img.src =
|
|
1089
|
+
img.src = `${BASE}api/import-thumb?id=${importing.id}&n=${c.value}`;
|
|
929
1090
|
c.closest(".thumb").append(img);
|
|
930
1091
|
}
|
|
931
1092
|
}
|
|
@@ -943,7 +1104,7 @@ $("import-dialog").addEventListener("close", async () => {
|
|
|
943
1104
|
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).` : "") +
|
|
944
1105
|
(d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
|
|
945
1106
|
state.current = d.index;
|
|
946
|
-
try { localStorage.setItem(
|
|
1107
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
947
1108
|
loadMain();
|
|
948
1109
|
});
|
|
949
1110
|
|
|
@@ -1054,10 +1215,10 @@ async function requestDelete(eid) {
|
|
|
1054
1215
|
(slide >= 0 ? ` on slide ${slide + 1}` : "") + " will be removed from the slide.";
|
|
1055
1216
|
$("delete-files").innerHTML = info.files.map((f, i) => f.usedElsewhere
|
|
1056
1217
|
? `<div class="file kept"><span>📄</span><span><code>${esc(f.rel)}</code>
|
|
1057
|
-
<span class="why"
|
|
1218
|
+
<span class="why">${f.usedBy?.length ? `Also used by ${esc(f.usedBy.join(", "))}` : "Still used elsewhere in the deck"}, so it is kept.</span></span></div>`
|
|
1058
1219
|
: `<label class="file"><input type="checkbox" data-file="${esc(f.rel)}" checked>
|
|
1059
1220
|
<span>Also delete <code>${esc(f.rel)}</code> (${fmtBytes(f.bytes)})
|
|
1060
|
-
<span class="why">Not used anywhere else in the deck.</span></span></label>`).join("");
|
|
1221
|
+
<span class="why">Not used anywhere else in the deck, or by the other decks in its folder.</span></span></label>`).join("");
|
|
1061
1222
|
const choice = await new Promise((resolve) => {
|
|
1062
1223
|
const d = $("delete-dialog");
|
|
1063
1224
|
d.addEventListener("close", () => resolve(d.returnValue), { once: true });
|
|
@@ -1090,7 +1251,7 @@ const kindOf = (f) => (f.type.startsWith("image/") || IMAGE_EXT.test(f.name) ? "
|
|
|
1090
1251
|
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
|
|
1091
1252
|
|
|
1092
1253
|
async function uploadBlob(blob, name) {
|
|
1093
|
-
const res = await fetch("
|
|
1254
|
+
const res = await fetch(BASE + "api/upload", { method: "POST", body: blob,
|
|
1094
1255
|
headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
|
|
1095
1256
|
const d = await res.json().catch(() => ({}));
|
|
1096
1257
|
if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
|
|
@@ -1178,7 +1339,7 @@ function renderAttachments() {
|
|
|
1178
1339
|
a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}` : "",
|
|
1179
1340
|
fmtBytes(a.bytes), a.droppedAt ? `dropped on slide ${a.droppedAt.slide.index + 1}` : ""].filter(Boolean).join(" · ");
|
|
1180
1341
|
return `<span class="att ${a.status}" title="${esc(a.path || a.name)}">
|
|
1181
|
-
${a.kind === "image" ? `<img class="thumb" src="${a.preview || "
|
|
1342
|
+
${a.kind === "image" ? `<img class="thumb" src="${a.preview || BASE + "deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
|
|
1182
1343
|
<span class="meta"><b>${esc(a.path ? a.path.split("/").pop() : a.name)}</b><span>${esc(info)}</span></span>
|
|
1183
1344
|
${a.status === "uploading" ? '<span class="spinner"></span>' : ""}
|
|
1184
1345
|
<button class="x" data-unattach="${a.id}" aria-label="Remove ${esc(a.name)}">×</button></span>`;
|