pindeck 3.1.0-beta.1 → 3.2.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 +142 -58
- package/cli.mjs +19 -9
- package/front.mjs +138 -43
- package/lib/agent-process.mjs +4 -1
- package/lib/agent-tools.mjs +5 -2
- package/lib/asset-roots.mjs +22 -0
- package/lib/capabilities.mjs +2 -2
- package/lib/claude.mjs +10 -2
- package/lib/deck-files.mjs +5 -3
- package/lib/deck-list.mjs +28 -5
- package/lib/deck-target.mjs +21 -3
- package/lib/eids.mjs +15 -6
- package/lib/login.mjs +15 -1
- package/lib/models.mjs +10 -4
- package/lib/pdf.mjs +18 -2
- package/lib/platform-notice.mjs +2 -2
- package/lib/servers.mjs +91 -0
- package/package.json +2 -2
- package/public/bridge.js +56 -7
- package/public/editor.css +8 -2
- package/public/editor.js +191 -83
- package/public/events-worker.js +47 -0
- package/public/index.html +1 -1
- package/server.mjs +452 -155
package/public/editor.js
CHANGED
|
@@ -47,10 +47,22 @@ function loadMain() {
|
|
|
47
47
|
$("stage").appendChild(f);
|
|
48
48
|
if (pendingMain) pendingMain.remove();
|
|
49
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);
|
|
50
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;
|
|
51
62
|
function loadMeasure() {
|
|
52
63
|
measure?.iframe.remove();
|
|
53
|
-
measureReady = new Promise((
|
|
64
|
+
measureReady = new Promise((resolve, reject) => { measureResolve = resolve; measureReject = reject; });
|
|
65
|
+
measureReady.catch(() => {});
|
|
54
66
|
const f = document.createElement("iframe");
|
|
55
67
|
f.className = "measure";
|
|
56
68
|
f.setAttribute("aria-hidden", "true");
|
|
@@ -60,19 +72,52 @@ function loadMeasure() {
|
|
|
60
72
|
$("stage").appendChild(f);
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
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) {
|
|
65
109
|
if (role === "measure") {
|
|
66
|
-
if (measure && measure.iframe.contentWindow === win) { measure.bridge =
|
|
110
|
+
if (measure && measure.iframe.contentWindow === win) { measure.bridge = bridgeOf(win); measureResolve(measure.bridge); }
|
|
67
111
|
return;
|
|
68
112
|
}
|
|
69
113
|
if (!pendingMain || pendingMain.contentWindow !== win) return;
|
|
70
114
|
const old = main;
|
|
115
|
+
const bridge = bridgeOf(win);
|
|
71
116
|
main = { iframe: pendingMain, bridge };
|
|
72
117
|
pendingMain = null;
|
|
73
118
|
main.iframe.classList.remove("loading");
|
|
74
119
|
old?.iframe.remove();
|
|
75
|
-
|
|
120
|
+
stageProblem(null);
|
|
76
121
|
state.slides = inf.slides;
|
|
77
122
|
state.current = Math.min(state.current, state.slides.length - 1);
|
|
78
123
|
if (inf.current !== state.current) bridge.goto(state.current);
|
|
@@ -125,13 +170,20 @@ window.__editorHost = {
|
|
|
125
170
|
loadMain(); // re-render: math, footer numbers, and the file as saved
|
|
126
171
|
}, "Could not save the text.");
|
|
127
172
|
},
|
|
128
|
-
commitSplit(op
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
131
178
|
const d = await res.json().catch(() => ({}));
|
|
132
|
-
if (!res.ok) {
|
|
133
|
-
|
|
134
|
-
|
|
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);
|
|
135
187
|
},
|
|
136
188
|
commitRemove(eid) {
|
|
137
189
|
queueEdit(async () => {
|
|
@@ -141,7 +193,11 @@ window.__editorHost = {
|
|
|
141
193
|
}, "Could not remove the bullet.");
|
|
142
194
|
},
|
|
143
195
|
refresh() { queueEdit(() => loadMain(), "Could not reload the slide."); },
|
|
144
|
-
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
|
+
},
|
|
145
201
|
requestDelete(eid) { requestDelete(eid); },
|
|
146
202
|
dropFiles(files, at) { attachFiles(files, at); },
|
|
147
203
|
dragOver(on) { $("drop-hint").hidden = !on; },
|
|
@@ -211,9 +267,11 @@ function toast(msg, { html = false, ms = 3200 } = {}) {
|
|
|
211
267
|
toastTimer = setTimeout(() => ($("toast").hidden = true), ms);
|
|
212
268
|
}
|
|
213
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))]);
|
|
214
272
|
async function measureSlide(index) {
|
|
215
|
-
const b = await measureReady;
|
|
216
|
-
return b.measure(index);
|
|
273
|
+
const b = await within(8000, measureReady, "The measuring frame");
|
|
274
|
+
return within(8000, b.measure(index), "Measuring the slide");
|
|
217
275
|
}
|
|
218
276
|
// one measurement at a time: the measure frame has a single "current slide"
|
|
219
277
|
let measureChain = Promise.resolve();
|
|
@@ -523,7 +581,7 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
523
581
|
});
|
|
524
582
|
|
|
525
583
|
$("present").onclick = () => {
|
|
526
|
-
window.open(`${BASE}deck/${
|
|
584
|
+
window.open(`${BASE}deck/${state.slides[state.current]?.hash || ""}`, "_blank");
|
|
527
585
|
};
|
|
528
586
|
|
|
529
587
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -545,7 +603,6 @@ function pinsChanged() {
|
|
|
545
603
|
main?.bridge.renderPins(state.pins);
|
|
546
604
|
renderPinTray();
|
|
547
605
|
renderSlides();
|
|
548
|
-
api("pins", { pins: state.pins });
|
|
549
606
|
}
|
|
550
607
|
function shortEid(p) {
|
|
551
608
|
if (p.tag?.startsWith("footer")) return "footer";
|
|
@@ -565,7 +622,7 @@ function renderPinTray() {
|
|
|
565
622
|
}
|
|
566
623
|
$("pin-tray").addEventListener("click", (e) => {
|
|
567
624
|
const x = e.target.closest("[data-x]");
|
|
568
|
-
if (x) return
|
|
625
|
+
if (x) return editorHost.removePin(+x.dataset.x);
|
|
569
626
|
if (e.target.closest("[data-unsel]")) return main?.bridge.deselect();
|
|
570
627
|
if (e.target.closest("[data-sel]")) return main?.bridge.highlight(state.sel.eid);
|
|
571
628
|
const chip = e.target.closest(".chip");
|
|
@@ -605,6 +662,8 @@ function md(s) {
|
|
|
605
662
|
an older one says so (see the error listener below). */
|
|
606
663
|
const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
|
|
607
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;
|
|
608
667
|
function entryHtml(e) {
|
|
609
668
|
switch (e.kind) {
|
|
610
669
|
case "user":
|
|
@@ -617,22 +676,23 @@ function entryHtml(e) {
|
|
|
617
676
|
case "tool":
|
|
618
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>`;
|
|
619
678
|
case "edit": {
|
|
620
|
-
const t = state.turns.get(e.turnId);
|
|
621
|
-
return `<span class="files ${
|
|
622
|
-
|
|
623
|
-
${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>" : ""}`;
|
|
624
683
|
}
|
|
625
684
|
case "turn": {
|
|
626
|
-
|
|
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;
|
|
627
687
|
const parts = [];
|
|
628
688
|
if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
|
|
629
689
|
if (e.files?.length) {
|
|
630
|
-
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) =>
|
|
631
691
|
`${esc(f.file)} ` + (f.binary ? `<span class="add">(binary)</span>`
|
|
632
692
|
: `<span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`)).join(", ")}</span>`);
|
|
633
|
-
parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
|
|
634
|
-
if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
|
|
635
|
-
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>");
|
|
636
696
|
} else if (!e.error) parts.push("No file changes");
|
|
637
697
|
if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
|
|
638
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">` +
|
|
@@ -734,7 +794,15 @@ async function send() {
|
|
|
734
794
|
({ path, kind, name, bytes, width, height, duration, sampleRate, channels, waveform, pages, pageSize, droppedAt })),
|
|
735
795
|
current: s ? { index: state.current, eid: s.eid } : null,
|
|
736
796
|
});
|
|
737
|
-
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
|
+
}
|
|
738
806
|
$("input").value = "";
|
|
739
807
|
autosize();
|
|
740
808
|
state.shot = null;
|
|
@@ -747,9 +815,9 @@ async function send() {
|
|
|
747
815
|
}
|
|
748
816
|
}
|
|
749
817
|
$("send").onclick = send;
|
|
750
|
-
$("stop").onclick = () => api("stop", {});
|
|
818
|
+
$("stop").onclick = () => { $("working-text").textContent = "Stopping…"; api("stop", {}); };
|
|
751
819
|
$("new-chat").onclick = async () => {
|
|
752
|
-
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;
|
|
753
821
|
await api("new-chat", {});
|
|
754
822
|
};
|
|
755
823
|
function autosize() {
|
|
@@ -771,7 +839,10 @@ function handleKey(k, mod = {}) {
|
|
|
771
839
|
else if (k === "v" || k === "V") setMode("select");
|
|
772
840
|
else if (k === "h" || k === "H") toggleHidden();
|
|
773
841
|
else if (k === "Escape") { if (state.mode === "pin") setMode("select"); else main?.bridge.deselect(); }
|
|
774
|
-
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
|
+
}
|
|
775
846
|
else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete(state.sel.eid);
|
|
776
847
|
else if (k.startsWith("Arrow") && state.sel && state.mode === "select") {
|
|
777
848
|
const step = mod.shift ? 10 : 1;
|
|
@@ -782,6 +853,10 @@ function handleKey(k, mod = {}) {
|
|
|
782
853
|
}
|
|
783
854
|
document.addEventListener("keydown", (e) => {
|
|
784
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;
|
|
785
860
|
const meta = e.metaKey || e.ctrlKey;
|
|
786
861
|
if (meta && (e.key === "z" || e.key === "Z")) { e.preventDefault(); return handleKey(e.key, { meta, shift: e.shiftKey }); }
|
|
787
862
|
if (meta || e.altKey) return;
|
|
@@ -794,7 +869,10 @@ document.addEventListener("keydown", (e) => {
|
|
|
794
869
|
/* ---- Server events ---------------------------------------------------------------------------------- */
|
|
795
870
|
let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
|
|
796
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;
|
|
797
874
|
state.status = s;
|
|
875
|
+
if (undoMoved) rerenderTurns(); // the Undo button moves to the change that is now the most recent
|
|
798
876
|
// the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
|
|
799
877
|
if (s.deck) {
|
|
800
878
|
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
@@ -811,7 +889,7 @@ function applyStatus(s) {
|
|
|
811
889
|
renderEffortMenu();
|
|
812
890
|
$("new-chat").disabled = !!s.busy;
|
|
813
891
|
$("stop").hidden = !s.busy || !!s.switching;
|
|
814
|
-
$("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…";
|
|
815
893
|
$("conn").classList.toggle("on", !!s.running);
|
|
816
894
|
$("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
|
|
817
895
|
// only the selected backend's availability matters: Codex missing is no news to someone using Claude
|
|
@@ -829,7 +907,7 @@ function applyStatus(s) {
|
|
|
829
907
|
imp.disabled = !!why(caps.pptxImport);
|
|
830
908
|
imp.title = why(caps.pptxImport);
|
|
831
909
|
if (why(caps[provider])) $("conn").title = caps[provider].reason;
|
|
832
|
-
// where the server runs, when it is not this Mac's usual setup: "Linux
|
|
910
|
+
// where the server runs, when it is not this Mac's usual setup: "Linux", with what is off
|
|
833
911
|
const names = { claude: "Claude", codex: "OpenAI Codex", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
|
|
834
912
|
pdfExport: "PDF export", pdfPages: "Pictures of PDF pages", pptxImport: "PowerPoint import",
|
|
835
913
|
pptxPictures: "Pictures of PowerPoint slides", audioConvert: "Waveforms of non-WAV audio" };
|
|
@@ -844,59 +922,89 @@ function applyStatus(s) {
|
|
|
844
922
|
shownProblem = s.deckProblem;
|
|
845
923
|
renderToolbar();
|
|
846
924
|
}
|
|
847
|
-
|
|
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
|
+
}
|
|
848
952
|
const es = new EventSource(BASE + "api/events");
|
|
849
|
-
es.addEventListener(
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
953
|
+
for (const [type, fn] of Object.entries(on)) es.addEventListener(type, (ev) => fn(JSON.parse(ev.data)));
|
|
954
|
+
es.onerror = onError;
|
|
955
|
+
}
|
|
956
|
+
function connect() {
|
|
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
|
+
},
|
|
895
1003
|
// EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
|
|
896
1004
|
// which asks for a new login link
|
|
897
|
-
|
|
1005
|
+
}, async () => {
|
|
898
1006
|
try { if ((await api("ping")).status === 403) location.reload(); } catch {}
|
|
899
|
-
};
|
|
1007
|
+
});
|
|
900
1008
|
}
|
|
901
1009
|
|
|
902
1010
|
/* ---- New slides --------------------------------------------------------------------------------------
|
|
@@ -1107,10 +1215,10 @@ async function requestDelete(eid) {
|
|
|
1107
1215
|
(slide >= 0 ? ` on slide ${slide + 1}` : "") + " will be removed from the slide.";
|
|
1108
1216
|
$("delete-files").innerHTML = info.files.map((f, i) => f.usedElsewhere
|
|
1109
1217
|
? `<div class="file kept"><span>📄</span><span><code>${esc(f.rel)}</code>
|
|
1110
|
-
<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>`
|
|
1111
1219
|
: `<label class="file"><input type="checkbox" data-file="${esc(f.rel)}" checked>
|
|
1112
1220
|
<span>Also delete <code>${esc(f.rel)}</code> (${fmtBytes(f.bytes)})
|
|
1113
|
-
<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("");
|
|
1114
1222
|
const choice = await new Promise((resolve) => {
|
|
1115
1223
|
const d = $("delete-dialog");
|
|
1116
1224
|
d.addEventListener("close", () => resolve(d.returnValue), { once: true });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* One event stream for all of this browser's PinDeck tabs on one port, behind the front.
|
|
2
|
+
|
|
3
|
+
Browsers open at most 6 connections to one host and port, and each editor tab held one open for its deck's
|
|
4
|
+
events, so a sixth tab never loaded and the others stalled. The tabs now share this worker's stream: each
|
|
5
|
+
tab (a port here) names its deck, and the front (front.mjs, /events) passes that deck's events on, marked
|
|
6
|
+
with the tab. Each tab still gets its deck's events as if it had its own stream, starting with "hello". */
|
|
7
|
+
|
|
8
|
+
const tabs = new Map(); // tab id -> { port, deck }
|
|
9
|
+
const stream = crypto.randomUUID?.() || String(Math.random()).slice(2) + String(Date.now());
|
|
10
|
+
let source = null, seq = 0;
|
|
11
|
+
|
|
12
|
+
const tell = (body) => fetch("/events/subscribe", { method: "POST", headers: { "content-type": "application/json" },
|
|
13
|
+
body: JSON.stringify({ stream, ...body }) }).catch(() => {});
|
|
14
|
+
|
|
15
|
+
function open() {
|
|
16
|
+
source = new EventSource(`/events?stream=${encodeURIComponent(stream)}`);
|
|
17
|
+
// "ready" comes first on every (re)connect: the front has a new stream, with no decks yet
|
|
18
|
+
source.addEventListener("ready", () => { for (const [tab, t] of tabs) tell({ tab, deck: t.deck, on: true }); });
|
|
19
|
+
source.addEventListener("deck", (ev) => {
|
|
20
|
+
const m = JSON.parse(ev.data);
|
|
21
|
+
tabs.get(m.tab)?.port.postMessage({ type: m.type, data: m.data });
|
|
22
|
+
});
|
|
23
|
+
source.onerror = () => { for (const t of tabs.values()) t.port.postMessage({ type: "error" }); };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onconnect = (e) => {
|
|
27
|
+
const port = e.ports[0];
|
|
28
|
+
let tab = null;
|
|
29
|
+
const leave = () => {
|
|
30
|
+
if (!tab) return;
|
|
31
|
+
tabs.delete(tab);
|
|
32
|
+
tell({ tab, on: false });
|
|
33
|
+
tab = null;
|
|
34
|
+
if (!tabs.size) { source?.close(); source = null; }
|
|
35
|
+
};
|
|
36
|
+
port.onmessage = ({ data }) => {
|
|
37
|
+
if (data?.subscribe) {
|
|
38
|
+
leave();
|
|
39
|
+
tab = String(++seq);
|
|
40
|
+
tabs.set(tab, { port, deck: String(data.subscribe) });
|
|
41
|
+
if (!source) open();
|
|
42
|
+
else if (source.readyState === EventSource.OPEN) tell({ tab, deck: String(data.subscribe), on: true });
|
|
43
|
+
}
|
|
44
|
+
if (data?.close) leave();
|
|
45
|
+
};
|
|
46
|
+
port.start();
|
|
47
|
+
};
|
package/public/index.html
CHANGED
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
|
|
103
103
|
<p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; the assistant can put it on a slide
|
|
104
104
|
or, for a paper or report, take the details from it.</p>
|
|
105
|
-
<p>The assistant edits <code id="deck-file">index.html</code
|
|
105
|
+
<p>The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
|
|
106
106
|
<code>brand.css</code> is locked.</p>
|
|
107
107
|
</div>
|
|
108
108
|
</div>
|