pindeck 0.0.0-stage → 2.8.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/LICENSE +21 -0
- package/README.md +321 -2
- package/cli.mjs +97 -0
- package/lib/claude.mjs +70 -0
- package/lib/diff.mjs +46 -0
- package/lib/eids.mjs +611 -0
- package/lib/layout.mjs +37 -0
- package/lib/omml.mjs +175 -0
- package/lib/pdf-render.jxa.js +54 -0
- package/lib/pdf.mjs +68 -0
- package/lib/pptx-import.mjs +682 -0
- package/lib/pptx-render.mjs +100 -0
- package/lib/pptx.mjs +340 -0
- package/lib/system-prompt.md +97 -0
- package/lib/waveform.mjs +67 -0
- package/lib/xml.mjs +55 -0
- package/lib/zip.mjs +46 -0
- package/package.json +39 -4
- package/public/apple-touch-icon.png +0 -0
- package/public/bridge.js +963 -0
- package/public/editor.css +281 -0
- package/public/editor.js +1112 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +8 -0
- package/public/index.html +165 -0
- package/server.mjs +1166 -0
package/public/editor.js
ADDED
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
/* PinDeck UI: slide list, canvas with pins, chat with Claude Code. */
|
|
2
|
+
|
|
3
|
+
const TOKEN = document.querySelector('meta[name="editor-token"]').content;
|
|
4
|
+
const $ = (id) => document.getElementById(id);
|
|
5
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
6
|
+
const api = (p, body) => fetch("/api/" + p, {
|
|
7
|
+
method: body === undefined ? "GET" : "POST",
|
|
8
|
+
headers: { "x-editor-token": TOKEN, "content-type": "application/json" },
|
|
9
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const savedSlide = (() => { try { return +localStorage.getItem("editor.slide") || 0; } catch { return 0; } })();
|
|
13
|
+
const state = {
|
|
14
|
+
status: {}, slides: [], current: savedSlide, pins: [], mode: "select", sel: null, // mode: "select" (move, resize) or "pin"
|
|
15
|
+
busy: false, status: {}, turns: new Map(), // turnId -> { undone }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/* ---- Frames ---------------------------------------------------------------------------- */
|
|
19
|
+
let main = null; // { iframe, bridge }
|
|
20
|
+
let pendingMain = null; // iframe being loaded (double buffering avoids a white flash)
|
|
21
|
+
let measure = null; // { iframe, bridge }
|
|
22
|
+
let measureReady = null, measureResolve = null;
|
|
23
|
+
|
|
24
|
+
// showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
|
|
25
|
+
function deckSrc(mode, extra = "") { return `/deck/index.html?editor=${mode}&showHiddenSlides=true${extra}`; }
|
|
26
|
+
|
|
27
|
+
function loadMain() {
|
|
28
|
+
const f = document.createElement("iframe");
|
|
29
|
+
f.className = "loading";
|
|
30
|
+
f.title = "Slide";
|
|
31
|
+
f.src = deckSrc("main", `&start=${state.current}`);
|
|
32
|
+
$("stage").appendChild(f);
|
|
33
|
+
if (pendingMain) pendingMain.remove();
|
|
34
|
+
pendingMain = f;
|
|
35
|
+
}
|
|
36
|
+
function loadMeasure() {
|
|
37
|
+
measure?.iframe.remove();
|
|
38
|
+
measureReady = new Promise((r) => (measureResolve = r));
|
|
39
|
+
const f = document.createElement("iframe");
|
|
40
|
+
f.className = "measure";
|
|
41
|
+
f.setAttribute("aria-hidden", "true");
|
|
42
|
+
f.tabIndex = -1;
|
|
43
|
+
f.src = deckSrc("measure");
|
|
44
|
+
measure = { iframe: f, bridge: null };
|
|
45
|
+
$("stage").appendChild(f);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
window.__editorHost = {
|
|
49
|
+
bridgeReady(role, bridge, win) {
|
|
50
|
+
if (role === "measure") {
|
|
51
|
+
if (measure && measure.iframe.contentWindow === win) { measure.bridge = bridge; measureResolve(bridge); }
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!pendingMain || pendingMain.contentWindow !== win) return;
|
|
55
|
+
const old = main;
|
|
56
|
+
main = { iframe: pendingMain, bridge };
|
|
57
|
+
pendingMain = null;
|
|
58
|
+
main.iframe.classList.remove("loading");
|
|
59
|
+
old?.iframe.remove();
|
|
60
|
+
const inf = bridge.info();
|
|
61
|
+
state.slides = inf.slides;
|
|
62
|
+
state.current = Math.min(state.current, state.slides.length - 1);
|
|
63
|
+
if (inf.current !== state.current) bridge.goto(state.current);
|
|
64
|
+
bridge.setMode(state.mode);
|
|
65
|
+
bridge.setEditable(!state.busy);
|
|
66
|
+
refreshPinsFromSlides();
|
|
67
|
+
bridge.renderPins(state.pins);
|
|
68
|
+
if (state.sel) bridge.selectEid(state.sel.eid);
|
|
69
|
+
if (state.pendingTitleEdit) { state.pendingTitleEdit = false; setTimeout(() => bridge.editTitle(), 50); }
|
|
70
|
+
renderSlides();
|
|
71
|
+
renderToolbar();
|
|
72
|
+
loadNotes();
|
|
73
|
+
},
|
|
74
|
+
slideChanged(index) {
|
|
75
|
+
state.current = index;
|
|
76
|
+
try { localStorage.setItem("editor.slide", String(index)); } catch {}
|
|
77
|
+
renderSlides();
|
|
78
|
+
renderToolbar();
|
|
79
|
+
loadNotes();
|
|
80
|
+
api("current", { index, eid: state.slides[index]?.eid });
|
|
81
|
+
},
|
|
82
|
+
addPin(pin) {
|
|
83
|
+
const n = state.pins.reduce((m, p) => Math.max(m, p.n), 0) + 1;
|
|
84
|
+
state.pins.push({ ...pin, n });
|
|
85
|
+
pinsChanged();
|
|
86
|
+
},
|
|
87
|
+
removePin(n) {
|
|
88
|
+
state.pins = state.pins.filter((p) => p.n !== n);
|
|
89
|
+
pinsChanged();
|
|
90
|
+
},
|
|
91
|
+
key(k, mod) { handleKey(k, mod); },
|
|
92
|
+
selectionChanged(info) { state.sel = info; renderPinTray(); },
|
|
93
|
+
commitEdit(op, label) { commitEdit(op, label); },
|
|
94
|
+
async textSource(eid, path) {
|
|
95
|
+
flushNudge();
|
|
96
|
+
await editChain;
|
|
97
|
+
const res = await api(`source?${new URLSearchParams({ eid, path: JSON.stringify(path || []) })}`);
|
|
98
|
+
const d = await res.json().catch(() => ({}));
|
|
99
|
+
if (!res.ok) { toast(d.error || "Could not load the text."); return null; }
|
|
100
|
+
return d.html;
|
|
101
|
+
},
|
|
102
|
+
commitText(edit) {
|
|
103
|
+
queueEdit(async () => {
|
|
104
|
+
const res = await api("text", edit);
|
|
105
|
+
if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not save the text.");
|
|
106
|
+
loadMain(); // re-render: math, footer numbers, and the file as saved
|
|
107
|
+
}, "Could not save the text.");
|
|
108
|
+
},
|
|
109
|
+
commitSplit(op, done) {
|
|
110
|
+
queueEdit(async () => {
|
|
111
|
+
const res = await api("split", op);
|
|
112
|
+
const d = await res.json().catch(() => ({}));
|
|
113
|
+
if (!res.ok) { toast(d.error || "Could not add the bullet."); loadMain(); return; }
|
|
114
|
+
done?.(d.eid);
|
|
115
|
+
}, "Could not add the bullet.");
|
|
116
|
+
},
|
|
117
|
+
commitRemove(eid) {
|
|
118
|
+
queueEdit(async () => {
|
|
119
|
+
const res = await api("remove", { eid });
|
|
120
|
+
if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not remove the bullet.");
|
|
121
|
+
loadMain();
|
|
122
|
+
}, "Could not remove the bullet.");
|
|
123
|
+
},
|
|
124
|
+
refresh() { queueEdit(() => loadMain(), "Could not reload the slide."); },
|
|
125
|
+
editingChanged(on) { state.editing = on; },
|
|
126
|
+
requestDelete(eid) { requestDelete(eid); },
|
|
127
|
+
dropFiles(files, at) { attachFiles(files, at); },
|
|
128
|
+
dragOver(on) { $("drop-hint").hidden = !on; },
|
|
129
|
+
toast(msg) { toast(msg); },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/* ---- Hand edits (drag, resize, nudge) ---------------------------------------------------------- */
|
|
133
|
+
let editChain = Promise.resolve(), nudgeAcc = null, nudgeTimer = null;
|
|
134
|
+
// Saves run one after another. An edit that throws (offline, server gone) is reported and the queue
|
|
135
|
+
// carries on, so one failure never silently skips the edits after it.
|
|
136
|
+
function queueEdit(fn, failMsg = "Could not save the change.") {
|
|
137
|
+
editChain = editChain.catch(() => {}).then(fn).catch(() => { toast(failMsg); });
|
|
138
|
+
return editChain;
|
|
139
|
+
}
|
|
140
|
+
function postEdit(op, label) {
|
|
141
|
+
return queueEdit(async () => {
|
|
142
|
+
const res = await api("edit", { ops: [op], label });
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
toast((await res.json().catch(() => ({}))).error || "Could not save the change.");
|
|
145
|
+
loadMain(); // show the file as it is
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function flushNudge() {
|
|
150
|
+
clearTimeout(nudgeTimer);
|
|
151
|
+
if (!nudgeAcc) return;
|
|
152
|
+
const n = nudgeAcc;
|
|
153
|
+
nudgeAcc = null;
|
|
154
|
+
postEdit(n.op, `Moved ${n.name} by ${n.dx}, ${n.dy} px`);
|
|
155
|
+
}
|
|
156
|
+
function commitEdit(op, label) {
|
|
157
|
+
if (label?.nudge) { // arrow keys: one change per burst of key presses
|
|
158
|
+
if (nudgeAcc && nudgeAcc.op.eid !== op.eid) flushNudge();
|
|
159
|
+
nudgeAcc ??= { name: label.name, dx: 0, dy: 0 };
|
|
160
|
+
nudgeAcc.dx += label.dx; nudgeAcc.dy += label.dy; nudgeAcc.op = op;
|
|
161
|
+
clearTimeout(nudgeTimer);
|
|
162
|
+
nudgeTimer = setTimeout(flushNudge, 600);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
flushNudge();
|
|
166
|
+
postEdit(op, label);
|
|
167
|
+
}
|
|
168
|
+
async function undoRedo(which) {
|
|
169
|
+
flushNudge();
|
|
170
|
+
await editChain;
|
|
171
|
+
const res = await api(which, {});
|
|
172
|
+
if (!res.ok) toast((await res.json().catch(() => ({}))).error || `Nothing to ${which}.`);
|
|
173
|
+
}
|
|
174
|
+
let toastTimer = null;
|
|
175
|
+
function toast(msg, { html = false, ms = 3200 } = {}) {
|
|
176
|
+
$("toast")[html ? "innerHTML" : "textContent"] = msg;
|
|
177
|
+
$("toast").hidden = false;
|
|
178
|
+
clearTimeout(toastTimer);
|
|
179
|
+
toastTimer = setTimeout(() => ($("toast").hidden = true), ms);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function measureSlide(index) {
|
|
183
|
+
const b = await measureReady;
|
|
184
|
+
return b.measure(index);
|
|
185
|
+
}
|
|
186
|
+
// one measurement at a time: the measure frame has a single "current slide"
|
|
187
|
+
let measureChain = Promise.resolve();
|
|
188
|
+
const queueMeasure = (index) => (measureChain = measureChain.then(() => measureSlide(index), () => measureSlide(index)));
|
|
189
|
+
|
|
190
|
+
/* ---- Slides ---------------------------------------------------------------------------------- */
|
|
191
|
+
function goto(i) {
|
|
192
|
+
if (i < 0 || i >= state.slides.length) return;
|
|
193
|
+
main?.bridge.goto(i);
|
|
194
|
+
}
|
|
195
|
+
function renderSlides() {
|
|
196
|
+
const counts = {};
|
|
197
|
+
state.pins.forEach((p) => (counts[p.slide.index] = (counts[p.slide.index] || 0) + 1));
|
|
198
|
+
$("slide-list").innerHTML = state.slides.map((s, i) => `
|
|
199
|
+
<li data-i="${i}" class="${i === state.current ? "active" : ""} ${s.hidden ? "hidden-slide" : ""}" title="${esc(s.eid)}" draggable="true">
|
|
200
|
+
<span class="num">${i + 1}</span>
|
|
201
|
+
<span><div class="t">${esc(s.title)}</div>${s.sub ? `<div class="s">${esc(s.sub)}</div>` : ""}
|
|
202
|
+
${s.kind !== "content" ? `<span class="kind">${esc(s.kind.replace(/-slide$/, ""))}</span>` : ""}
|
|
203
|
+
${s.hidden ? '<span class="kind hid">hidden</span>' : ""}</span>
|
|
204
|
+
${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" : "Hide this slide (skipped when presenting)"}"
|
|
206
|
+
aria-label="${s.hidden ? "Show" : "Hide"} slide ${i + 1}">${s.hidden ? EYE_ICON : EYE_OFF_ICON}</button>
|
|
207
|
+
</li>`).join("");
|
|
208
|
+
$("slide-list").querySelector("li.active")?.scrollIntoView({ block: "nearest" });
|
|
209
|
+
}
|
|
210
|
+
$("slide-list").addEventListener("click", (e) => {
|
|
211
|
+
const eye = e.target.closest("[data-eye]");
|
|
212
|
+
if (eye) { e.stopPropagation(); return toggleHidden(+eye.dataset.eye); }
|
|
213
|
+
const li = e.target.closest("li[data-i]");
|
|
214
|
+
if (li) goto(+li.dataset.i);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/* ---- Move slides: drag one in the slide list -------------------------------------------------------
|
|
218
|
+
A line shows where it will go; the editor then opens the moved slide at its new place. */
|
|
219
|
+
const slideList = $("slide-list");
|
|
220
|
+
let dragFrom = null;
|
|
221
|
+
// insertion index for a pointer at y: before the first slide whose middle is below it, else at the end
|
|
222
|
+
function dropIndex(y) {
|
|
223
|
+
const lis = [...slideList.querySelectorAll("li[data-i]")];
|
|
224
|
+
const i = lis.findIndex((li) => { const r = li.getBoundingClientRect(); return y < r.top + r.height / 2; });
|
|
225
|
+
return i < 0 ? lis.length : i;
|
|
226
|
+
}
|
|
227
|
+
function showDrop(i) {
|
|
228
|
+
slideList.querySelectorAll(".drop-before, .drop-after").forEach((li) => li.classList.remove("drop-before", "drop-after"));
|
|
229
|
+
if (i == null || i === dragFrom || i === dragFrom + 1) return; // dropping there would not move it
|
|
230
|
+
const lis = slideList.querySelectorAll("li[data-i]");
|
|
231
|
+
if (i < lis.length) lis[i].classList.add("drop-before");
|
|
232
|
+
else lis[lis.length - 1]?.classList.add("drop-after");
|
|
233
|
+
}
|
|
234
|
+
slideList.addEventListener("dragstart", (e) => {
|
|
235
|
+
const li = e.target.closest("li[data-i]");
|
|
236
|
+
if (!li || state.busy) return e.preventDefault();
|
|
237
|
+
dragFrom = +li.dataset.i;
|
|
238
|
+
li.classList.add("dragging");
|
|
239
|
+
e.dataTransfer.effectAllowed = "move";
|
|
240
|
+
e.dataTransfer.setData("text/plain", state.slides[dragFrom]?.eid || "");
|
|
241
|
+
});
|
|
242
|
+
slideList.addEventListener("dragover", (e) => {
|
|
243
|
+
if (dragFrom == null) return; // not one of our slides (a file, say)
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
e.dataTransfer.dropEffect = "move";
|
|
246
|
+
showDrop(dropIndex(e.clientY));
|
|
247
|
+
});
|
|
248
|
+
slideList.addEventListener("dragleave", (e) => { if (!slideList.contains(e.relatedTarget)) showDrop(null); });
|
|
249
|
+
slideList.addEventListener("dragend", () => {
|
|
250
|
+
showDrop(null);
|
|
251
|
+
slideList.querySelector(".dragging")?.classList.remove("dragging");
|
|
252
|
+
dragFrom = null;
|
|
253
|
+
});
|
|
254
|
+
slideList.addEventListener("drop", (e) => {
|
|
255
|
+
if (dragFrom == null) return;
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
const from = dragFrom, to = dropIndex(e.clientY);
|
|
258
|
+
showDrop(null);
|
|
259
|
+
if (to !== from && to !== from + 1) moveSlideTo(from, to);
|
|
260
|
+
});
|
|
261
|
+
async function moveSlideTo(from, to) { // to: insertion index in the current list
|
|
262
|
+
const s = state.slides[from];
|
|
263
|
+
if (!s) return;
|
|
264
|
+
if (state.busy) return toast("Claude is working. Wait until it has finished.");
|
|
265
|
+
flushNudge();
|
|
266
|
+
await editChain;
|
|
267
|
+
const res = await api("move-slide", { eid: s.eid, before: state.slides[to]?.eid ?? null });
|
|
268
|
+
const d = await res.json().catch(() => ({}));
|
|
269
|
+
if (!res.ok) return toast(d.error || "Could not move the slide.");
|
|
270
|
+
if (d.unchanged) return;
|
|
271
|
+
state.current = d.index; // the reload opens the moved slide
|
|
272
|
+
try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
|
|
273
|
+
loadMain();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ---- Speaker notes: the field under the slide -------------------------------------------------------
|
|
277
|
+
Shows the current slide's <aside class="notes"> and saves it as a hand edit when the field loses focus
|
|
278
|
+
or on ⌘Enter; Esc puts back what the file says. Notes with markup other than line breaks are edited
|
|
279
|
+
as their HTML ("HTML" in the header). Typing is never overwritten by a reload. */
|
|
280
|
+
const notesBox = $("notes");
|
|
281
|
+
let notes = { eid: null, text: "", html: false }; // the notes as the file has them
|
|
282
|
+
let notesSeq = 0;
|
|
283
|
+
const notesDirty = () => !!notes.eid && notesBox.value.trim() !== notes.text.trim();
|
|
284
|
+
function notesStatus(msg) {
|
|
285
|
+
$("notes-status").textContent = msg ?? (notes.html ? "HTML" : "");
|
|
286
|
+
$("notes-status").title = msg ? "" : notes.html ? "These notes contain formatting, so you edit them as HTML" : "";
|
|
287
|
+
}
|
|
288
|
+
async function loadNotes() {
|
|
289
|
+
const eid = state.slides[state.current]?.eid;
|
|
290
|
+
if (!eid || document.activeElement === notesBox) return;
|
|
291
|
+
const seq = ++notesSeq;
|
|
292
|
+
await editChain.catch(() => {}); // a save still on its way would otherwise be read back as the old text
|
|
293
|
+
if (seq !== notesSeq || notesDirty()) return;
|
|
294
|
+
const res = await api(`notes?${new URLSearchParams({ eid })}`).catch(() => null);
|
|
295
|
+
const d = res?.ok ? await res.json().catch(() => null) : null;
|
|
296
|
+
if (seq !== notesSeq || document.activeElement === notesBox || notesDirty()) return;
|
|
297
|
+
if (!d) { // detach from the previous slide, so its notes are not shown, or saved over, as this one's
|
|
298
|
+
notes = { eid: null, text: "", html: false };
|
|
299
|
+
notesBox.value = "";
|
|
300
|
+
notesBox.disabled = true;
|
|
301
|
+
return notesStatus("Could not load");
|
|
302
|
+
}
|
|
303
|
+
notes = { eid, text: d.text || "", html: !!d.html };
|
|
304
|
+
notesBox.value = notes.text;
|
|
305
|
+
notesBox.disabled = false;
|
|
306
|
+
notesStatus();
|
|
307
|
+
}
|
|
308
|
+
function saveNotes() {
|
|
309
|
+
if (!notesDirty()) return Promise.resolve(true);
|
|
310
|
+
const edit = { eid: notes.eid, text: notesBox.value, html: notes.html };
|
|
311
|
+
flushNudge();
|
|
312
|
+
const run = async () => {
|
|
313
|
+
notesStatus("Saving…");
|
|
314
|
+
let res, d;
|
|
315
|
+
try {
|
|
316
|
+
res = await api("notes", edit);
|
|
317
|
+
d = await res.json().catch(() => ({}));
|
|
318
|
+
} catch {} // a network failure must not break the shared edit queue
|
|
319
|
+
if (!res?.ok) {
|
|
320
|
+
notesStatus("Not saved");
|
|
321
|
+
toast(d?.error || "Could not save the notes.");
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (notes.eid === edit.eid) notes.text = edit.text;
|
|
325
|
+
notesStatus("Saved");
|
|
326
|
+
setTimeout(() => { if ($("notes-status").textContent === "Saved") notesStatus(); }, 1500);
|
|
327
|
+
return true;
|
|
328
|
+
};
|
|
329
|
+
return queueEdit(run, "Could not save the notes.");
|
|
330
|
+
}
|
|
331
|
+
notesBox.addEventListener("blur", async () => {
|
|
332
|
+
if (await saveNotes()) loadNotes(); // retain a failed draft and its original slide id for retry
|
|
333
|
+
});
|
|
334
|
+
notesBox.addEventListener("keydown", (e) => {
|
|
335
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); notesBox.blur(); }
|
|
336
|
+
else if (e.key === "Escape") { e.preventDefault(); notesBox.value = notes.text; notesBox.blur(); }
|
|
337
|
+
});
|
|
338
|
+
// closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
|
|
339
|
+
addEventListener("pagehide", () => {
|
|
340
|
+
if (notesDirty() && !state.busy)
|
|
341
|
+
navigator.sendBeacon(`/api/notes?t=${TOKEN}`, new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
|
|
342
|
+
});
|
|
343
|
+
function setNotesOpen(open) {
|
|
344
|
+
$("notes-panel").classList.toggle("collapsed", !open);
|
|
345
|
+
$("notes-toggle").setAttribute("aria-expanded", String(open));
|
|
346
|
+
try { localStorage.setItem("editor.notes", open ? "open" : "closed"); } catch {}
|
|
347
|
+
}
|
|
348
|
+
$("notes-toggle").onclick = () => setNotesOpen($("notes-panel").classList.contains("collapsed"));
|
|
349
|
+
// height of the notes: drag the panel's top edge (or ↑ ↓ on it); double-click for the default size
|
|
350
|
+
const NOTES_H = 96, NOTES_MIN = 44;
|
|
351
|
+
function setNotesHeight(h, save = true) {
|
|
352
|
+
const max = Math.max(NOTES_MIN, $("stage").parentElement.clientHeight * 0.6);
|
|
353
|
+
h = Math.round(Math.min(max, Math.max(NOTES_MIN, h)));
|
|
354
|
+
notesBox.style.height = h + "px";
|
|
355
|
+
$("notes-split").setAttribute("aria-valuenow", String(h));
|
|
356
|
+
if (save) try { localStorage.setItem("editor.notesHeight", String(h)); } catch {}
|
|
357
|
+
}
|
|
358
|
+
$("notes-split").addEventListener("pointerdown", (e) => {
|
|
359
|
+
if (e.button !== 0) return;
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
const split = $("notes-split"), y0 = e.clientY, h0 = notesBox.offsetHeight;
|
|
362
|
+
split.setPointerCapture(e.pointerId);
|
|
363
|
+
document.body.classList.add("resizing-notes");
|
|
364
|
+
const move = (ev) => setNotesHeight(h0 - (ev.clientY - y0));
|
|
365
|
+
const up = () => {
|
|
366
|
+
document.body.classList.remove("resizing-notes");
|
|
367
|
+
split.removeEventListener("pointermove", move);
|
|
368
|
+
split.removeEventListener("pointerup", up);
|
|
369
|
+
split.removeEventListener("pointercancel", up);
|
|
370
|
+
};
|
|
371
|
+
split.addEventListener("pointermove", move);
|
|
372
|
+
split.addEventListener("pointerup", up);
|
|
373
|
+
split.addEventListener("pointercancel", up);
|
|
374
|
+
});
|
|
375
|
+
$("notes-split").addEventListener("dblclick", () => setNotesHeight(NOTES_H));
|
|
376
|
+
$("notes-split").addEventListener("keydown", (e) => {
|
|
377
|
+
const step = e.shiftKey ? 64 : 16;
|
|
378
|
+
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
e.stopPropagation(); // not a slide change
|
|
381
|
+
setNotesHeight(notesBox.offsetHeight + (e.key === "ArrowUp" ? step : -step));
|
|
382
|
+
});
|
|
383
|
+
setNotesHeight((() => { try { return +localStorage.getItem("editor.notesHeight") || NOTES_H; } catch { return NOTES_H; } })(), false);
|
|
384
|
+
setNotesOpen((() => { try { return localStorage.getItem("editor.notes") !== "closed"; } catch { return true; } })());
|
|
385
|
+
|
|
386
|
+
/* ---- Hide and show slides -------------------------------------------------------------------------
|
|
387
|
+
A hidden slide gets data-visibility="hidden" (reveal.js): it stays in the deck and in PinDeck, but is
|
|
388
|
+
skipped when presenting and in PDF exports. */
|
|
389
|
+
const EYE_ICON = `<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3C4.4 3 1.7 5.6.8 8c.9 2.4 3.6 5 7.2 5s6.3-2.6 7.2-5C14.3 5.6 11.6 3 8 3zm0 8.2A3.2 3.2 0 1 1 8 4.8a3.2 3.2 0 0 1 0 6.4zM8 6.3a1.7 1.7 0 1 0 0 3.4 1.7 1.7 0 0 0 0-3.4z"/></svg>`;
|
|
390
|
+
const EYE_OFF_ICON = `<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.2 1.5 1.1 2.6l2.3 2.3C2.2 5.8 1.3 6.9.8 8c.9 2.4 3.6 5 7.2 5 1.3 0 2.5-.3 3.5-.9l2.3 2.3 1.1-1.1zM8 11.2A3.2 3.2 0 0 1 4.9 7.2l1.3 1.3a1.7 1.7 0 0 0 1.3 1.3l1.3 1.3c-.3.1-.5.1-.8.1zM8 3c-1 0-1.9.2-2.7.5l1.4 1.4A3.2 3.2 0 0 1 11 9.2l2.2 2.2c.9-.9 1.6-2 2-3.4C14.3 5.6 11.6 3 8 3z"/></svg>`;
|
|
391
|
+
async function toggleHidden(index = state.current) {
|
|
392
|
+
const s = state.slides[index];
|
|
393
|
+
if (!s) return;
|
|
394
|
+
if (state.busy) return toast("Claude is working. Wait until it has finished.");
|
|
395
|
+
flushNudge();
|
|
396
|
+
await editChain;
|
|
397
|
+
const res = await api("slide-hidden", { eid: s.eid, hidden: !s.hidden });
|
|
398
|
+
const d = await res.json().catch(() => ({}));
|
|
399
|
+
if (!res.ok) return toast(d.error || "Could not change the slide.");
|
|
400
|
+
loadMain();
|
|
401
|
+
}
|
|
402
|
+
function renderToolbar() {
|
|
403
|
+
const s = state.slides[state.current];
|
|
404
|
+
$("slide-pos").textContent = s ? `${state.current + 1} / ${state.slides.length}` : "– / –";
|
|
405
|
+
$("pin-toggle").setAttribute("aria-pressed", String(state.mode === "pin"));
|
|
406
|
+
$("select-toggle").setAttribute("aria-pressed", String(state.mode === "select"));
|
|
407
|
+
$("pin-hint").hidden = state.mode !== "pin";
|
|
408
|
+
const st = state.status;
|
|
409
|
+
$("undo").disabled = !st.undo || state.busy;
|
|
410
|
+
$("redo").disabled = !st.redo || state.busy;
|
|
411
|
+
$("undo").title = st.undo ? `Undo: ${st.undo} (⌘Z)` : "Undo (⌘Z)";
|
|
412
|
+
$("redo").title = st.redo ? `Redo: ${st.redo} (⇧⌘Z)` : "Redo (⇧⌘Z)";
|
|
413
|
+
}
|
|
414
|
+
$("prev").onclick = () => goto(state.current - 1);
|
|
415
|
+
$("next").onclick = () => goto(state.current + 1);
|
|
416
|
+
$("pin-toggle").onclick = () => setMode(state.mode === "pin" ? "select" : "pin");
|
|
417
|
+
$("select-toggle").onclick = () => setMode("select");
|
|
418
|
+
$("undo").onclick = () => undoRedo("undo");
|
|
419
|
+
$("redo").onclick = () => undoRedo("redo");
|
|
420
|
+
/* ---- PDF export ---------------------------------------------------------------------------- */
|
|
421
|
+
/* Drop-down menus: one open at a time; an outside click or Esc closes them. */
|
|
422
|
+
function setMenu(btn, menu, open) {
|
|
423
|
+
document.querySelectorAll(".menu").forEach((m) => {
|
|
424
|
+
const show = m === menu && open;
|
|
425
|
+
m.hidden = !show;
|
|
426
|
+
m.parentElement.querySelector("[aria-haspopup]")?.setAttribute("aria-expanded", String(show));
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function wireMenu(btnId, menuId, beforeOpen) {
|
|
430
|
+
$(btnId).addEventListener("click", (e) => {
|
|
431
|
+
e.stopPropagation();
|
|
432
|
+
const open = $(menuId).hidden;
|
|
433
|
+
if (open) beforeOpen?.();
|
|
434
|
+
setMenu($(btnId), $(menuId), open);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const closeMenus = () => setMenu(null, null, false);
|
|
438
|
+
document.addEventListener("click", (e) => { if (!e.target.closest(".menu")) closeMenus(); });
|
|
439
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }, true);
|
|
440
|
+
const setExportMenu = (open) => setMenu($("export-btn"), $("export-menu"), open);
|
|
441
|
+
wireMenu("export-btn", "export-menu");
|
|
442
|
+
$("export-menu").addEventListener("click", async (e) => {
|
|
443
|
+
const item = e.target.closest("[data-export]");
|
|
444
|
+
if (!item) return;
|
|
445
|
+
setExportMenu(false);
|
|
446
|
+
const kind = item.dataset.export, label = kind === "presentation" ? "presentation" : "handout";
|
|
447
|
+
const btn = $("export-btn");
|
|
448
|
+
btn.disabled = true;
|
|
449
|
+
btn.textContent = "Exporting…";
|
|
450
|
+
toast(`Creating the ${label} PDF…`, { ms: 60000 });
|
|
451
|
+
try {
|
|
452
|
+
flushNudge();
|
|
453
|
+
await editChain; // export what is saved, including the latest hand edits
|
|
454
|
+
const res = await api("export", { kind, includeHidden: $("export-hidden").checked });
|
|
455
|
+
const d = await res.json().catch(() => ({}));
|
|
456
|
+
if (!res.ok) throw new Error(d.error || "Export failed.");
|
|
457
|
+
toast(`Saved ${esc(d.rel)} (${Math.round(d.bytes / 1024)} kB)<a href="${d.url}" target="_blank" rel="noopener">Open</a>`,
|
|
458
|
+
{ html: true, ms: 10000 });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
toast(String(err.message || err), { ms: 6000 });
|
|
461
|
+
} finally {
|
|
462
|
+
btn.disabled = false;
|
|
463
|
+
btn.textContent = "Export ▾";
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
$("present").onclick = () => {
|
|
468
|
+
const ix = main?.bridge.indices(state.current) || { h: 0, v: 0 };
|
|
469
|
+
window.open(`/deck/index.html#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
473
|
+
function setMode(m) {
|
|
474
|
+
state.mode = m;
|
|
475
|
+
if (m !== "select") state.sel = null;
|
|
476
|
+
main?.bridge.setMode(m);
|
|
477
|
+
renderToolbar();
|
|
478
|
+
renderPinTray();
|
|
479
|
+
}
|
|
480
|
+
function refreshPinsFromSlides() {
|
|
481
|
+
// after a reload, slide indices may have moved: re-resolve by slide id, drop pins whose slide is gone
|
|
482
|
+
state.pins = state.pins.flatMap((p) => {
|
|
483
|
+
const i = state.slides.findIndex((s) => s.eid === p.slide.eid);
|
|
484
|
+
return i < 0 ? [] : [{ ...p, slide: { ...p.slide, index: i } }];
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
function pinsChanged() {
|
|
488
|
+
main?.bridge.renderPins(state.pins);
|
|
489
|
+
renderPinTray();
|
|
490
|
+
renderSlides();
|
|
491
|
+
api("pins", { pins: state.pins });
|
|
492
|
+
}
|
|
493
|
+
function shortEid(p) {
|
|
494
|
+
if (p.tag?.startsWith("footer")) return "footer";
|
|
495
|
+
return p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, "");
|
|
496
|
+
}
|
|
497
|
+
function renderPinTray() {
|
|
498
|
+
const sel = state.sel;
|
|
499
|
+
$("pin-tray").innerHTML = (sel ? `
|
|
500
|
+
<span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. Claude sees it as “this”.">
|
|
501
|
+
<span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
|
|
502
|
+
<button class="x" data-unsel aria-label="Deselect">×</button></span>` : "") + state.pins.map((p) => `
|
|
503
|
+
<span class="chip" data-n="${p.n}" title="${esc(p.eid)}${p.text ? " — " + esc(p.text.slice(0, 80)) : ""}">
|
|
504
|
+
<span class="n">${p.n}</span> S${p.slide.index + 1} · ${esc(shortEid(p))}${p.word ? ` “${esc(p.word)}”` : ""}
|
|
505
|
+
${p.locked ? '<span class="lock" title="Brand-locked">🔒</span>' : ""}
|
|
506
|
+
<button class="x" data-x="${p.n}" aria-label="Remove pin ${p.n}">×</button></span>`).join("");
|
|
507
|
+
$("clear-pins").hidden = !state.pins.length;
|
|
508
|
+
}
|
|
509
|
+
$("pin-tray").addEventListener("click", (e) => {
|
|
510
|
+
const x = e.target.closest("[data-x]");
|
|
511
|
+
if (x) return window.__editorHost.removePin(+x.dataset.x);
|
|
512
|
+
if (e.target.closest("[data-unsel]")) return main?.bridge.deselect();
|
|
513
|
+
if (e.target.closest("[data-sel]")) return main?.bridge.highlight(state.sel.eid);
|
|
514
|
+
const chip = e.target.closest(".chip");
|
|
515
|
+
if (!chip) return;
|
|
516
|
+
const p = state.pins.find((p) => p.n === +chip.dataset.n);
|
|
517
|
+
if (!p) return;
|
|
518
|
+
if (p.slide.index !== state.current) goto(p.slide.index);
|
|
519
|
+
setTimeout(() => main?.bridge.highlight(p.eid), 60);
|
|
520
|
+
});
|
|
521
|
+
$("pin-tray").addEventListener("mouseover", (e) => {
|
|
522
|
+
const chip = e.target.closest(".chip");
|
|
523
|
+
if (!chip || chip.dataset.hl) return;
|
|
524
|
+
chip.dataset.hl = "1";
|
|
525
|
+
setTimeout(() => delete chip.dataset.hl, 1500);
|
|
526
|
+
const p = state.pins.find((p) => p.n === +chip.dataset.n);
|
|
527
|
+
if (p && p.slide.index === state.current) main?.bridge.highlight(p.eid);
|
|
528
|
+
});
|
|
529
|
+
$("clear-pins").onclick = () => { state.pins = []; pinsChanged(); };
|
|
530
|
+
|
|
531
|
+
/* ---- Chat ----------------------------------------------------------------------------------------- */
|
|
532
|
+
const chatEl = $("chat");
|
|
533
|
+
const entryEls = new Map();
|
|
534
|
+
function md(s) {
|
|
535
|
+
// small, safe subset: code blocks, inline code, bold, lists, paragraphs
|
|
536
|
+
const blocks = [];
|
|
537
|
+
s = esc(s).replace(/```[\w-]*\n?([\s\S]*?)```/g, (_, c) => { blocks.push(c); return `\u0000${blocks.length - 1}\u0000`; });
|
|
538
|
+
const inline = (t) => t.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
539
|
+
const out = s.split(/\n{2,}/).map((para) => {
|
|
540
|
+
const lines = para.split("\n");
|
|
541
|
+
if (lines.every((l) => /^\s*[-*•]\s/.test(l) || !l.trim()))
|
|
542
|
+
return "<ul>" + lines.filter((l) => l.trim()).map((l) => `<li>${inline(l.replace(/^\s*[-*•]\s/, ""))}</li>`).join("") + "</ul>";
|
|
543
|
+
return `<p>${inline(lines.join("<br>"))}</p>`;
|
|
544
|
+
}).join("");
|
|
545
|
+
return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
|
|
546
|
+
}
|
|
547
|
+
function entryHtml(e) {
|
|
548
|
+
switch (e.kind) {
|
|
549
|
+
case "user":
|
|
550
|
+
return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
|
|
551
|
+
`<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) =>
|
|
552
|
+
`<span class="chip" title="${esc(p.eid)}"><span class="n">${p.n}</span> S${p.slide} · ${esc(p.label || p.eid)}</span>`).join("")}${
|
|
553
|
+
e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
|
|
554
|
+
`<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="/deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
|
|
555
|
+
case "assistant": return md(e.text);
|
|
556
|
+
case "tool":
|
|
557
|
+
return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
|
|
558
|
+
case "edit": {
|
|
559
|
+
const t = state.turns.get(e.turnId);
|
|
560
|
+
return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
|
|
561
|
+
<button class="small" data-diff="${e.turnId}">Diff</button>
|
|
562
|
+
${t && !t.undone ? `<button class="small" data-undo="${e.turnId}">Undo</button>` : t?.undone ? "<span>undone</span>" : ""}`;
|
|
563
|
+
}
|
|
564
|
+
case "turn": {
|
|
565
|
+
const t = e.turnId ? state.turns.get(e.turnId) : null;
|
|
566
|
+
const parts = [];
|
|
567
|
+
if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
|
|
568
|
+
if (e.files?.length) {
|
|
569
|
+
parts.push(`<span class="files ${t?.undone ? "undone" : ""}">Changed ${e.files.map((f) =>
|
|
570
|
+
`${esc(f.file)} <span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`).join(", ")}</span>`);
|
|
571
|
+
parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
|
|
572
|
+
if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
|
|
573
|
+
if (t?.undone) parts.push("<span>undone</span>");
|
|
574
|
+
} else if (!e.error) parts.push("No file changes");
|
|
575
|
+
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">` +
|
|
577
|
+
`Not allowed in PinDeck, so skipped: ${e.denials.map((d) => esc(`${d.name} ${d.detail}`)).join(", ")}</span>`);
|
|
578
|
+
if (e.duration) parts.push(`<span class="meta">${(e.duration / 1000).toFixed(1)} s</span>`);
|
|
579
|
+
return parts.join(" ");
|
|
580
|
+
}
|
|
581
|
+
default: return esc(e.text);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function renderEntry(e) {
|
|
585
|
+
$("chat-empty").hidden = true;
|
|
586
|
+
let el = entryEls.get(e.id);
|
|
587
|
+
if (!el) {
|
|
588
|
+
el = document.createElement("div");
|
|
589
|
+
entryEls.set(e.id, el);
|
|
590
|
+
chatEl.appendChild(el);
|
|
591
|
+
}
|
|
592
|
+
el.className = `msg ${e.kind}`;
|
|
593
|
+
el.innerHTML = entryHtml(e);
|
|
594
|
+
el.__entry = e;
|
|
595
|
+
}
|
|
596
|
+
function rerenderTurns() { entryEls.forEach((el) => { if (["turn", "edit"].includes(el.__entry?.kind)) el.innerHTML = entryHtml(el.__entry); }); }
|
|
597
|
+
const scrollChat = () => { chatEl.scrollTop = chatEl.scrollHeight; };
|
|
598
|
+
function clearChat() {
|
|
599
|
+
entryEls.forEach((el) => el.remove());
|
|
600
|
+
entryEls.clear();
|
|
601
|
+
$("chat-empty").hidden = false;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
chatEl.addEventListener("click", async (e) => {
|
|
605
|
+
const d = e.target.closest("[data-diff]");
|
|
606
|
+
if (d) {
|
|
607
|
+
const res = await api(`turn/${d.dataset.diff}/diff`);
|
|
608
|
+
const txt = await res.text();
|
|
609
|
+
$("diff-body").innerHTML = txt.split("\n").map((l) => {
|
|
610
|
+
const c = l.startsWith("+++") || l.startsWith("---") ? "f" : l.startsWith("@@") ? "h" : l[0] === "+" ? "a" : l[0] === "-" ? "d" : "";
|
|
611
|
+
return `<span class="${c}">${esc(l) || " "}</span>`;
|
|
612
|
+
}).join("");
|
|
613
|
+
$("diff-dialog").showModal();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const u = e.target.closest("[data-undo]");
|
|
617
|
+
if (u) {
|
|
618
|
+
u.disabled = true;
|
|
619
|
+
flushNudge();
|
|
620
|
+
await editChain;
|
|
621
|
+
const res = await api(`turn/${u.dataset.undo}/undo`, {});
|
|
622
|
+
if (!res.ok) { u.disabled = false; toast((await res.json()).error); }
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
$("diff-close").onclick = () => $("diff-dialog").close();
|
|
626
|
+
|
|
627
|
+
function setBusy(busy) {
|
|
628
|
+
state.busy = busy;
|
|
629
|
+
$("notes").readOnly = busy; // hand edits wait until Claude has finished
|
|
630
|
+
$("working").hidden = !busy;
|
|
631
|
+
main?.bridge.setEditable(!busy);
|
|
632
|
+
$("send").hidden = busy;
|
|
633
|
+
$("stop").hidden = !busy;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function send() {
|
|
637
|
+
const text = $("input").value.trim();
|
|
638
|
+
if (!text || state.busy) return;
|
|
639
|
+
if (attachments.some((a) => a.status === "uploading")) return toast("Wait until the attachments have finished uploading.");
|
|
640
|
+
$("send").disabled = true;
|
|
641
|
+
try {
|
|
642
|
+
// measure every pinned slide plus the current one, in the user's own browser
|
|
643
|
+
flushNudge();
|
|
644
|
+
await editChain;
|
|
645
|
+
const idx = [...new Set([...state.pins.map((p) => p.slide.index), state.current])].slice(0, 4);
|
|
646
|
+
const layouts = [];
|
|
647
|
+
for (const i of idx) { try { layouts.push(await queueMeasure(i)); } catch {} }
|
|
648
|
+
const s = state.slides[state.current];
|
|
649
|
+
const res = await api("message", {
|
|
650
|
+
text, pins: state.pins, selection: state.sel, layouts, screenshot: state.shot || false,
|
|
651
|
+
attachments: attachments.filter((a) => a.status === "ready").map(({ path, kind, name, bytes, width, height, duration,
|
|
652
|
+
sampleRate, channels, waveform, pages, pageSize, droppedAt }) =>
|
|
653
|
+
({ path, kind, name, bytes, width, height, duration, sampleRate, channels, waveform, pages, pageSize, droppedAt })),
|
|
654
|
+
current: s ? { index: state.current, eid: s.eid } : null,
|
|
655
|
+
});
|
|
656
|
+
if (!res.ok) { alert((await res.json()).error || "Could not send."); return; }
|
|
657
|
+
$("input").value = "";
|
|
658
|
+
autosize();
|
|
659
|
+
state.shot = null;
|
|
660
|
+
clearAttachments();
|
|
661
|
+
state.pins = [];
|
|
662
|
+
pinsChanged();
|
|
663
|
+
if (state.mode === "pin") setMode("select");
|
|
664
|
+
} finally {
|
|
665
|
+
$("send").disabled = false;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
$("send").onclick = send;
|
|
669
|
+
$("stop").onclick = () => api("stop", {});
|
|
670
|
+
$("new-chat").onclick = async () => {
|
|
671
|
+
if (!confirm("Start a new conversation? Claude forgets this chat; your slides stay as they are.")) return;
|
|
672
|
+
await api("new-chat", {});
|
|
673
|
+
};
|
|
674
|
+
function autosize() {
|
|
675
|
+
const t = $("input");
|
|
676
|
+
t.style.height = "auto";
|
|
677
|
+
t.style.height = Math.min(220, Math.max(64, t.scrollHeight + 2)) + "px";
|
|
678
|
+
}
|
|
679
|
+
$("input").addEventListener("input", autosize);
|
|
680
|
+
$("input").addEventListener("keydown", (e) => {
|
|
681
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
|
|
682
|
+
if (e.key === "Escape") { $("input").blur(); if (state.mode === "pin") setMode("select"); }
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
/* ---- Keyboard ------------------------------------------------------------------------------------ */
|
|
686
|
+
function handleKey(k, mod = {}) {
|
|
687
|
+
if (mod.meta && (k === "z" || k === "Z")) return undoRedo(mod.shift ? "redo" : "undo");
|
|
688
|
+
if (mod.meta) return;
|
|
689
|
+
if (k === "p" || k === "P") setMode(state.mode === "pin" ? "select" : "pin");
|
|
690
|
+
else if (k === "v" || k === "V") setMode("select");
|
|
691
|
+
else if (k === "h" || k === "H") toggleHidden();
|
|
692
|
+
else if (k === "Escape") { if (state.mode === "pin") setMode("select"); else main?.bridge.deselect(); }
|
|
693
|
+
else if (k === "Enter") { if (!(state.sel && state.mode === "select" && main?.bridge.editSelection())) $("input").focus(); }
|
|
694
|
+
else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete(state.sel.eid);
|
|
695
|
+
else if (k.startsWith("Arrow") && state.sel && state.mode === "select") {
|
|
696
|
+
const step = mod.shift ? 10 : 1;
|
|
697
|
+
main?.bridge.nudge(k === "ArrowLeft" ? -step : k === "ArrowRight" ? step : 0, k === "ArrowUp" ? -step : k === "ArrowDown" ? step : 0);
|
|
698
|
+
}
|
|
699
|
+
else if (k === "ArrowLeft" || k === "ArrowUp" || k === "PageUp") goto(state.current - 1);
|
|
700
|
+
else if (k === "ArrowRight" || k === "ArrowDown" || k === "PageDown" || k === " ") goto(state.current + 1);
|
|
701
|
+
}
|
|
702
|
+
document.addEventListener("keydown", (e) => {
|
|
703
|
+
if (e.target.closest?.("input, textarea, dialog")) return;
|
|
704
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
705
|
+
if (meta && (e.key === "z" || e.key === "Z")) { e.preventDefault(); return handleKey(e.key, { meta, shift: e.shiftKey }); }
|
|
706
|
+
if (meta || e.altKey) return;
|
|
707
|
+
if (["p", "P", "v", "V", "h", "H", "Escape", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "PageUp", "PageDown", "Delete", "Backspace"].includes(e.key)) {
|
|
708
|
+
e.preventDefault();
|
|
709
|
+
handleKey(e.key, { shift: e.shiftKey });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
/* ---- Server events ---------------------------------------------------------------------------------- */
|
|
714
|
+
function applyStatus(s) {
|
|
715
|
+
state.status = s;
|
|
716
|
+
// the deck's name in the header ("ocean-example"), its full path on hover
|
|
717
|
+
if (s.deck) {
|
|
718
|
+
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
719
|
+
$("deck-name").textContent = folder;
|
|
720
|
+
$("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
|
|
721
|
+
document.title = `${folder} · PinDeck`;
|
|
722
|
+
}
|
|
723
|
+
setBusy(!!s.busy);
|
|
724
|
+
$("model").textContent = modelLabel(s);
|
|
725
|
+
$("model-btn").disabled = !!s.busy;
|
|
726
|
+
$("conn").classList.toggle("on", !!s.running);
|
|
727
|
+
$("conn").title = s.running ? "Claude Code is running" : "Claude Code starts with the next message";
|
|
728
|
+
$("add-shot").disabled = !s.chrome;
|
|
729
|
+
$("add-shot").title = s.chrome ? "" : "Screenshots need Chrome or Chromium";
|
|
730
|
+
$("add-slide-btn").disabled = !!s.busy;
|
|
731
|
+
renderToolbar();
|
|
732
|
+
}
|
|
733
|
+
function connect() {
|
|
734
|
+
const es = new EventSource(`/api/events?t=${TOKEN}`);
|
|
735
|
+
es.addEventListener("hello", (ev) => {
|
|
736
|
+
const d = JSON.parse(ev.data);
|
|
737
|
+
clearChat();
|
|
738
|
+
state.turns = new Map(d.turns.map((t) => [t.id, t]));
|
|
739
|
+
d.chat.forEach(renderEntry);
|
|
740
|
+
applyStatus(d.status);
|
|
741
|
+
scrollChat();
|
|
742
|
+
});
|
|
743
|
+
es.addEventListener("chat", (ev) => {
|
|
744
|
+
const e = JSON.parse(ev.data);
|
|
745
|
+
if ((e.kind === "turn" || e.kind === "edit") && e.turnId) state.turns.set(e.turnId, { id: e.turnId, undone: false });
|
|
746
|
+
renderEntry(e);
|
|
747
|
+
scrollChat();
|
|
748
|
+
});
|
|
749
|
+
es.addEventListener("chat-update", (ev) => renderEntry(JSON.parse(ev.data)));
|
|
750
|
+
es.addEventListener("import", (ev) => showImportThumbs(JSON.parse(ev.data)));
|
|
751
|
+
es.addEventListener("status", (ev) => applyStatus(JSON.parse(ev.data)));
|
|
752
|
+
es.addEventListener("reset", () => { clearChat(); state.turns.clear(); });
|
|
753
|
+
es.addEventListener("undone", (ev) => {
|
|
754
|
+
const d = JSON.parse(ev.data);
|
|
755
|
+
const t = state.turns.get(d.turnId);
|
|
756
|
+
if (t) t.undone = d.undone !== false;
|
|
757
|
+
rerenderTurns();
|
|
758
|
+
});
|
|
759
|
+
es.addEventListener("files", (ev) => {
|
|
760
|
+
const { files, manual } = JSON.parse(ev.data);
|
|
761
|
+
const cssOnly = files.every((f) => f.endsWith(".css"));
|
|
762
|
+
if (manual && main) {
|
|
763
|
+
loadMeasure(); // the visible slide already shows the hand edit
|
|
764
|
+
} else if (cssOnly && main) {
|
|
765
|
+
main.bridge.reloadCss(files);
|
|
766
|
+
measure?.bridge?.reloadCss(files);
|
|
767
|
+
} else {
|
|
768
|
+
loadMain();
|
|
769
|
+
loadMeasure();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
es.addEventListener("measure", async (ev) => {
|
|
773
|
+
const { id, index } = JSON.parse(ev.data);
|
|
774
|
+
try { api("measure-result", { id, result: await queueMeasure(index) }); }
|
|
775
|
+
catch (err) { api("measure-result", { id, error: String(err) }); }
|
|
776
|
+
});
|
|
777
|
+
// EventSource reconnects by itself; if the server restarted, our token is stale: reload the page
|
|
778
|
+
es.onerror = async () => {
|
|
779
|
+
try { if ((await api("ping")).status === 403) location.reload(); } catch {}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* ---- New slides --------------------------------------------------------------------------------------
|
|
784
|
+
Content slide, section divider or a copy of the current slide, inserted after the current one and
|
|
785
|
+
modelled on the deck's own slides. The editor jumps to it and opens its title for typing. */
|
|
786
|
+
wireMenu("add-slide-btn", "add-slide-menu", () => {
|
|
787
|
+
const cur = state.slides[state.current];
|
|
788
|
+
$("add-slide-menu").querySelector('[data-slide="duplicate"] span').textContent =
|
|
789
|
+
cur ? `A copy of slide ${state.current + 1} to start from` : "A copy to start from";
|
|
790
|
+
});
|
|
791
|
+
$("add-slide-menu").addEventListener("click", async (e) => {
|
|
792
|
+
const item = e.target.closest("[data-slide]");
|
|
793
|
+
if (!item) return;
|
|
794
|
+
closeMenus();
|
|
795
|
+
if (item.dataset.slide === "import") return $("import-file").click();
|
|
796
|
+
if (state.busy) return toast("Claude is working. Wait until it has finished.");
|
|
797
|
+
flushNudge();
|
|
798
|
+
await editChain;
|
|
799
|
+
const after = state.slides[state.current]?.eid;
|
|
800
|
+
const res = await api("add-slide", { kind: item.dataset.slide, after });
|
|
801
|
+
const d = await res.json().catch(() => ({}));
|
|
802
|
+
if (!res.ok) return toast(d.error || "Could not add the slide.");
|
|
803
|
+
state.current = d.index; // the reload opens the new slide ...
|
|
804
|
+
state.pendingTitleEdit = item.dataset.slide !== "duplicate"; // ... with its title ready for typing
|
|
805
|
+
try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
|
|
806
|
+
loadMain();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
/* ---- Import from PowerPoint -------------------------------------------------------------------------
|
|
810
|
+
The file goes to the server, which lists its slides at once and makes thumbnails in the background
|
|
811
|
+
(with PowerPoint or Keynote). The chosen slides are added after the current one. */
|
|
812
|
+
let importing = null; // { id, name, slides }
|
|
813
|
+
$("import-file").addEventListener("change", async () => {
|
|
814
|
+
const file = $("import-file").files[0];
|
|
815
|
+
$("import-file").value = "";
|
|
816
|
+
if (!file) return;
|
|
817
|
+
toast(`Reading ${file.name}…`);
|
|
818
|
+
const res = await fetch("/api/import", { method: "POST", body: file,
|
|
819
|
+
headers: { "x-editor-token": TOKEN, "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
|
|
820
|
+
const d = await res.json().catch(() => ({}));
|
|
821
|
+
if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
|
|
822
|
+
importing = d;
|
|
823
|
+
$("import-title").textContent = `Import from ${d.name}`;
|
|
824
|
+
importStatus(d.render ? "Making slide pictures with PowerPoint…" : "PowerPoint or Keynote is needed for thumbnails and for diagrams; text is imported without them.");
|
|
825
|
+
$("import-list").innerHTML = d.slides.map((s) => `<li><label>
|
|
826
|
+
<div class="thumb"><input type="checkbox" value="${s.n}" ${s.hidden ? "" : "checked"}><span>${s.n}</span></div>
|
|
827
|
+
<div class="cap"><span class="n">${s.n}</span><span class="t">${esc(s.title || s.subtitle || "Untitled")}${s.title && s.subtitle ? ` · ${esc(s.subtitle)}` : ""}</span>${s.hidden ? '<span class="tag">hidden</span>' : ""}${s.complex.length ? `<span class="tag complex" title="${esc("Only approximated: " + s.complex.join("; "))}">complex</span>` : ""}</div>
|
|
828
|
+
</label></li>`).join("");
|
|
829
|
+
updateImportButton();
|
|
830
|
+
$("import-dialog").returnValue = ""; // Esc keeps the old value: it must not be "import" from the last time
|
|
831
|
+
$("import-dialog").showModal();
|
|
832
|
+
});
|
|
833
|
+
function importStatus(msg, error = false) {
|
|
834
|
+
$("import-status").textContent = msg;
|
|
835
|
+
$("import-status").classList.toggle("error", error);
|
|
836
|
+
}
|
|
837
|
+
const importChecks = () => [...$("import-list").querySelectorAll("input[type=checkbox]")];
|
|
838
|
+
function updateImportButton() {
|
|
839
|
+
const n = importChecks().filter((c) => c.checked).length;
|
|
840
|
+
// Claude's help with what the import only approximates (diagrams as pictures, lost build steps, ...)
|
|
841
|
+
const complex = importChecks().filter((c) => c.checked && importing.slides.find((s) => s.n === +c.value)?.complex.length).length;
|
|
842
|
+
$("import-refine-wrap").hidden = !complex;
|
|
843
|
+
$("import-refine-label").textContent = complex === 1 ? "Then ask Claude to improve the complex slide"
|
|
844
|
+
: `Then ask Claude to improve the ${complex} complex slides`;
|
|
845
|
+
$("import-go").textContent = n === 1 ? "Import 1 slide" : `Import ${n} slides`;
|
|
846
|
+
$("import-go").disabled = !n;
|
|
847
|
+
$("import-all").textContent = n === importChecks().length ? "Select none" : "Select all";
|
|
848
|
+
}
|
|
849
|
+
$("import-list").addEventListener("change", updateImportButton);
|
|
850
|
+
$("import-all").addEventListener("click", () => {
|
|
851
|
+
const all = importChecks().some((c) => !c.checked);
|
|
852
|
+
importChecks().forEach((c) => { c.checked = all; });
|
|
853
|
+
updateImportButton();
|
|
854
|
+
});
|
|
855
|
+
function showImportThumbs(ev) {
|
|
856
|
+
if (!importing || ev.id !== importing.id) return;
|
|
857
|
+
if (ev.error) return importStatus(`No slide pictures (${ev.error}). Text is imported; diagrams are left out.`, true);
|
|
858
|
+
importStatus(`Slide pictures by ${ev.app || "PowerPoint"}. Hidden slides are not selected.`);
|
|
859
|
+
for (const c of importChecks()) {
|
|
860
|
+
const img = new Image();
|
|
861
|
+
img.alt = "";
|
|
862
|
+
img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}&t=${TOKEN}`;
|
|
863
|
+
c.closest(".thumb").append(img);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
$("import-dialog").addEventListener("close", async () => {
|
|
867
|
+
if ($("import-dialog").returnValue !== "import" || !importing) return;
|
|
868
|
+
const slides = importChecks().filter((c) => c.checked).map((c) => +c.value);
|
|
869
|
+
if (state.busy) return toast("Claude is working. Wait until it has finished.");
|
|
870
|
+
flushNudge();
|
|
871
|
+
await editChain;
|
|
872
|
+
toast(`Importing ${slides.length} slide${slides.length > 1 ? "s" : ""}…`, { ms: 60000 });
|
|
873
|
+
const refine = !$("import-refine-wrap").hidden && $("import-refine").checked;
|
|
874
|
+
const res = await api("import-apply", { id: importing.id, slides, after: state.slides[state.current]?.eid, refine });
|
|
875
|
+
const d = await res.json().catch(() => ({}));
|
|
876
|
+
if (!res.ok) return toast(d.error || "The import failed.");
|
|
877
|
+
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).` : "") +
|
|
878
|
+
(d.refining ? ` Claude is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
|
|
879
|
+
state.current = d.index;
|
|
880
|
+
try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
|
|
881
|
+
loadMain();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
/* ---- Model and effort --------------------------------------------------------------------------------- */
|
|
885
|
+
const MODEL_CHOICES = [["opus", "Opus", "most capable, for layout and design work"], ["sonnet", "Sonnet", "faster, lighter on usage limits"]];
|
|
886
|
+
const EFFORT_CHOICES = [["low", "Low", "quick edits"], ["medium", "Medium", ""], ["high", "High", ""],
|
|
887
|
+
["xhigh", "Extra high", ""], ["max", "Max", "slowest, most careful"]];
|
|
888
|
+
function prettyModel(id) {
|
|
889
|
+
const m = /claude-(\w+)-(\d+)(?:-(\d+))?/.exec(id || "");
|
|
890
|
+
return m ? `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] && m[3].length <= 2 ? "." + m[3] : ""}` : id || "";
|
|
891
|
+
}
|
|
892
|
+
function modelLabel(s) {
|
|
893
|
+
const st = s.settings || {};
|
|
894
|
+
// the full name (e.g. "Sonnet 5.5") once Claude Code has reported it for the chosen model, else the choice
|
|
895
|
+
const full = prettyModel(s.model);
|
|
896
|
+
const model = full && (!st.model || full.toLowerCase().startsWith(st.model)) ? full : MODEL_CHOICES.find((c) => c[0] === st.model)?.[1] || full;
|
|
897
|
+
return [model, st.effort ? EFFORT_CHOICES.find((c) => c[0] === st.effort)?.[1].toLowerCase() + " effort" : ""].filter(Boolean).join(" · ");
|
|
898
|
+
}
|
|
899
|
+
function renderModelMenu() {
|
|
900
|
+
const st = state.status.settings || {};
|
|
901
|
+
const opt = (key, [value, label, desc]) => `<button class="opt" role="menuitemradio" aria-checked="${st[key] === value}"
|
|
902
|
+
data-set="${key}" data-value="${value}"><span class="tick">${st[key] === value ? "✓" : ""}</span>
|
|
903
|
+
<strong>${label}</strong>${desc ? `<span class="d muted">${desc}</span>` : ""}</button>`;
|
|
904
|
+
$("model-menu").innerHTML = `<div class="group">Model</div>${MODEL_CHOICES.map((c) => opt("model", c)).join("")}
|
|
905
|
+
<div class="sep"></div><div class="group">Effort: how long Claude thinks</div>${EFFORT_CHOICES.map((c) => opt("effort", c)).join("")}`;
|
|
906
|
+
}
|
|
907
|
+
wireMenu("model-btn", "model-menu", renderModelMenu);
|
|
908
|
+
$("model-menu").addEventListener("click", async (e) => {
|
|
909
|
+
const b = e.target.closest("[data-set]");
|
|
910
|
+
if (!b) return;
|
|
911
|
+
e.stopPropagation();
|
|
912
|
+
const res = await api("settings", { [b.dataset.set]: b.dataset.value });
|
|
913
|
+
const d = await res.json().catch(() => ({}));
|
|
914
|
+
if (!res.ok) return toast(d.error || "Could not change the setting.");
|
|
915
|
+
state.status.settings = d.settings;
|
|
916
|
+
renderModelMenu();
|
|
917
|
+
$("model").textContent = modelLabel(state.status);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
/* ---- Delete the selected element ---------------------------------------------------------------------
|
|
921
|
+
Asks first. Lists the files the element uses; those nothing else in the deck uses can be deleted
|
|
922
|
+
with it (ticked by default). Undo brings back the element and the files. */
|
|
923
|
+
let deleting = false;
|
|
924
|
+
async function requestDelete(eid) {
|
|
925
|
+
if (deleting || !eid) return;
|
|
926
|
+
if (state.busy) return toast("Claude is working. Wait until it has finished.");
|
|
927
|
+
deleting = true;
|
|
928
|
+
try {
|
|
929
|
+
flushNudge();
|
|
930
|
+
await editChain;
|
|
931
|
+
const res = await api(`delete-info?${new URLSearchParams({ eid })}`);
|
|
932
|
+
const info = await res.json().catch(() => ({}));
|
|
933
|
+
if (!res.ok) return toast(info.error || "Cannot delete this element.");
|
|
934
|
+
const name = info.eid.replace(/^[^/]+\//, "/");
|
|
935
|
+
const slide = state.slides.findIndex((s) => info.eid === s.eid || info.eid.startsWith(s.eid + "/"));
|
|
936
|
+
const isFig = info.tag.startsWith("figure") && info.eid !== eid;
|
|
937
|
+
$("delete-title").textContent = isFig ? "Delete this figure?" : `Delete ${name}?`;
|
|
938
|
+
$("delete-what").textContent = `${isFig ? `The picture and its caption (${name})` : info.tag}` +
|
|
939
|
+
(slide >= 0 ? ` on slide ${slide + 1}` : "") + " will be removed from the slide.";
|
|
940
|
+
$("delete-files").innerHTML = info.files.map((f, i) => f.usedElsewhere
|
|
941
|
+
? `<div class="file kept"><span>📄</span><span><code>${esc(f.rel)}</code>
|
|
942
|
+
<span class="why">Still used elsewhere in the deck, so it is kept.</span></span></div>`
|
|
943
|
+
: `<label class="file"><input type="checkbox" data-file="${esc(f.rel)}" checked>
|
|
944
|
+
<span>Also delete <code>${esc(f.rel)}</code> (${fmtBytes(f.bytes)})
|
|
945
|
+
<span class="why">Not used anywhere else in the deck.</span></span></label>`).join("");
|
|
946
|
+
const choice = await new Promise((resolve) => {
|
|
947
|
+
const d = $("delete-dialog");
|
|
948
|
+
d.addEventListener("close", () => resolve(d.returnValue), { once: true });
|
|
949
|
+
d.returnValue = "cancel";
|
|
950
|
+
d.showModal();
|
|
951
|
+
});
|
|
952
|
+
if (choice !== "delete") return;
|
|
953
|
+
const files = [...$("delete-files").querySelectorAll("input[data-file]:checked")].map((c) => c.dataset.file);
|
|
954
|
+
const r2 = await api("delete", { eid, files });
|
|
955
|
+
const d2 = await r2.json().catch(() => ({}));
|
|
956
|
+
if (!r2.ok) return toast(d2.error || "Could not delete the element.");
|
|
957
|
+
main?.bridge.deselect();
|
|
958
|
+
state.sel = null;
|
|
959
|
+
renderPinTray();
|
|
960
|
+
toast(`Deleted ${name}${d2.files.length ? ` and ${d2.files.length === 1 ? d2.files[0] : d2.files.length + " files"}` : ""}. ⌘Z to undo.`, { ms: 4500 });
|
|
961
|
+
} finally {
|
|
962
|
+
deleting = false;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/* ---- Attachments: figures, audio and PDFs for the next message ----------------------------------------
|
|
967
|
+
Files are uploaded at once into <deck>/figures/ (PDFs, reading material for Claude, outside the deck). Images get their pixel size, audio its duration and a
|
|
968
|
+
waveform SVG (bars, viewBox 1000 x 120, like the template's audio player), all computed here in the
|
|
969
|
+
browser. Removing an attachment before sending deletes the uploaded file again. */
|
|
970
|
+
let attachments = [], attachSeq = 0;
|
|
971
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|avif)$/i, AUDIO_EXT = /\.(wav|mp3|ogg|oga|m4a|aac|flac|opus)$/i;
|
|
972
|
+
const kindOf = (f) => (f.type.startsWith("image/") || IMAGE_EXT.test(f.name) ? "image"
|
|
973
|
+
: f.type.startsWith("audio/") || AUDIO_EXT.test(f.name) ? "audio"
|
|
974
|
+
: f.type === "application/pdf" || /\.pdf$/i.test(f.name) ? "pdf" : null);
|
|
975
|
+
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
|
|
976
|
+
|
|
977
|
+
async function uploadBlob(blob, name) {
|
|
978
|
+
const res = await fetch("/api/upload", { method: "POST", body: blob,
|
|
979
|
+
headers: { "x-editor-token": TOKEN, "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
|
|
980
|
+
const d = await res.json().catch(() => ({}));
|
|
981
|
+
if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
|
|
982
|
+
return d;
|
|
983
|
+
}
|
|
984
|
+
function imageSize(file) {
|
|
985
|
+
return new Promise((resolve) => {
|
|
986
|
+
const img = new Image();
|
|
987
|
+
img.onload = () => resolve(img.naturalWidth ? { width: img.naturalWidth, height: img.naturalHeight } : {});
|
|
988
|
+
img.onerror = () => resolve({});
|
|
989
|
+
img.src = URL.createObjectURL(file);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
async function audioInfo(file) {
|
|
993
|
+
try {
|
|
994
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
995
|
+
const audio = await ctx.decodeAudioData(await file.arrayBuffer());
|
|
996
|
+
ctx.close();
|
|
997
|
+
const n = 220, W = 1000, H = 120, len = audio.length;
|
|
998
|
+
const chans = [...Array(audio.numberOfChannels)].map((_, c) => audio.getChannelData(c));
|
|
999
|
+
const peaks = [];
|
|
1000
|
+
for (let i = 0; i < n; i++) {
|
|
1001
|
+
let m = 0;
|
|
1002
|
+
const a = Math.floor((i * len) / n), b = Math.floor(((i + 1) * len) / n);
|
|
1003
|
+
for (const ch of chans) for (let j = a; j < b; j += 4) m = Math.max(m, Math.abs(ch[j]));
|
|
1004
|
+
peaks.push(m);
|
|
1005
|
+
}
|
|
1006
|
+
const top = Math.max(...peaks) || 1;
|
|
1007
|
+
const bars = peaks.map((p, i) => {
|
|
1008
|
+
const h = Math.max(3, (p / top) * (H - 10));
|
|
1009
|
+
return `<rect x="${(i * W / n + 1.2).toFixed(1)}" y="${((H - h) / 2).toFixed(1)}" width="${(W / n - 2.4).toFixed(1)}" height="${h.toFixed(1)}" rx="2"/>`;
|
|
1010
|
+
}).join("");
|
|
1011
|
+
return { meta: { duration: audio.duration, sampleRate: audio.sampleRate, channels: audio.numberOfChannels },
|
|
1012
|
+
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><g fill="#6B7785">${bars}</g></svg>` };
|
|
1013
|
+
} catch {
|
|
1014
|
+
return { meta: {}, svg: null }; // a format the browser cannot decode: attach it without waveform
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async function attachFile(file, droppedAt) {
|
|
1018
|
+
const name = file.name && file.name !== "image.png" ? file.name : `pasted-image-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.png`;
|
|
1019
|
+
const kind = kindOf({ type: file.type || "", name });
|
|
1020
|
+
if (!kind) return toast(`${file.name || "This file"} is not an image, audio file or PDF.`);
|
|
1021
|
+
const a = { id: ++attachSeq, name, kind, status: "uploading", bytes: file.size, droppedAt,
|
|
1022
|
+
preview: kind === "image" ? URL.createObjectURL(file) : null };
|
|
1023
|
+
attachments.push(a);
|
|
1024
|
+
renderAttachments();
|
|
1025
|
+
try {
|
|
1026
|
+
let wave = null;
|
|
1027
|
+
if (kind === "image") Object.assign(a, await imageSize(file));
|
|
1028
|
+
else if (kind === "audio") { const info = await audioInfo(file); Object.assign(a, info.meta); wave = info.svg; }
|
|
1029
|
+
Object.assign(a, await uploadBlob(file, name));
|
|
1030
|
+
if (wave) {
|
|
1031
|
+
const w = await uploadBlob(new Blob([wave], { type: "image/svg+xml" }), a.path.split("/").pop().replace(/\.[^.]+$/, "") + "-wave.svg");
|
|
1032
|
+
a.waveform = w.path;
|
|
1033
|
+
}
|
|
1034
|
+
a.status = attachments.includes(a) ? "ready" : "removed";
|
|
1035
|
+
if (a.status === "removed") api("upload-remove", { path: a.path, waveform: a.waveform }); // removed while uploading
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
a.status = "error";
|
|
1038
|
+
a.error = String(err.message || err);
|
|
1039
|
+
}
|
|
1040
|
+
renderAttachments();
|
|
1041
|
+
}
|
|
1042
|
+
function attachFiles(files, droppedAt) { [...files].forEach((f) => attachFile(f, droppedAt)); }
|
|
1043
|
+
function removeAttachment(id) {
|
|
1044
|
+
const a = attachments.find((x) => x.id === id);
|
|
1045
|
+
if (!a) return;
|
|
1046
|
+
attachments = attachments.filter((x) => x !== a);
|
|
1047
|
+
if (a.status === "ready") api("upload-remove", { path: a.path, waveform: a.waveform });
|
|
1048
|
+
if (a.preview) URL.revokeObjectURL(a.preview);
|
|
1049
|
+
renderAttachments();
|
|
1050
|
+
}
|
|
1051
|
+
function clearAttachments() {
|
|
1052
|
+
attachments.forEach((a) => a.preview && URL.revokeObjectURL(a.preview));
|
|
1053
|
+
attachments = [];
|
|
1054
|
+
renderAttachments();
|
|
1055
|
+
}
|
|
1056
|
+
function renderAttachments() {
|
|
1057
|
+
const shot = state.shot ? `<span class="att" title="A screenshot is taken when you send the message">
|
|
1058
|
+
<span class="icon-box">📷</span><span class="meta"><b>Screenshot</b><span>slide ${state.shot.index + 1}, with its pins</span></span>
|
|
1059
|
+
<button class="x" data-unshot aria-label="Remove the screenshot">×</button></span>` : "";
|
|
1060
|
+
$("attach-tray").innerHTML = shot + attachments.map((a) => {
|
|
1061
|
+
const info = a.status === "uploading" ? "uploading…" : a.status === "error" ? a.error
|
|
1062
|
+
: [a.kind === "image" && a.width ? `${a.width} × ${a.height}` : "", a.duration ? `${a.duration.toFixed(1)} s` : "",
|
|
1063
|
+
a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}` : "",
|
|
1064
|
+
fmtBytes(a.bytes), a.droppedAt ? `dropped on slide ${a.droppedAt.slide.index + 1}` : ""].filter(Boolean).join(" · ");
|
|
1065
|
+
return `<span class="att ${a.status}" title="${esc(a.path || a.name)}">
|
|
1066
|
+
${a.kind === "image" ? `<img class="thumb" src="${a.preview || "/deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
|
|
1067
|
+
<span class="meta"><b>${esc(a.path ? a.path.split("/").pop() : a.name)}</b><span>${esc(info)}</span></span>
|
|
1068
|
+
${a.status === "uploading" ? '<span class="spinner"></span>' : ""}
|
|
1069
|
+
<button class="x" data-unattach="${a.id}" aria-label="Remove ${esc(a.name)}">×</button></span>`;
|
|
1070
|
+
}).join("");
|
|
1071
|
+
}
|
|
1072
|
+
$("attach-tray").addEventListener("click", (e) => {
|
|
1073
|
+
const x = e.target.closest("[data-unattach]");
|
|
1074
|
+
if (x) removeAttachment(+x.dataset.unattach);
|
|
1075
|
+
if (e.target.closest("[data-unshot]")) { state.shot = null; renderAttachments(); }
|
|
1076
|
+
});
|
|
1077
|
+
/* The + menu in the composer: a file, or a screenshot of the current slide */
|
|
1078
|
+
wireMenu("add-btn", "add-menu");
|
|
1079
|
+
$("add-menu").addEventListener("click", (e) => {
|
|
1080
|
+
const item = e.target.closest("[data-add]");
|
|
1081
|
+
if (!item || item.disabled) return;
|
|
1082
|
+
closeMenus();
|
|
1083
|
+
if (item.dataset.add === "file") return $("file-input").click();
|
|
1084
|
+
const s = state.slides[state.current];
|
|
1085
|
+
if (s) { state.shot = { index: state.current, eid: s.eid }; renderAttachments(); }
|
|
1086
|
+
});
|
|
1087
|
+
$("file-input").onchange = () => { attachFiles($("file-input").files); $("file-input").value = ""; };
|
|
1088
|
+
$("input").addEventListener("paste", (e) => {
|
|
1089
|
+
const files = [...(e.clipboardData?.files || [])];
|
|
1090
|
+
if (!files.length) return;
|
|
1091
|
+
e.preventDefault();
|
|
1092
|
+
attachFiles(files);
|
|
1093
|
+
});
|
|
1094
|
+
// drag and drop onto the chat (the slide frame forwards its own drops through the bridge)
|
|
1095
|
+
let dragDepth = 0;
|
|
1096
|
+
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
|
|
1097
|
+
document.addEventListener("dragenter", (e) => { if (hasFiles(e)) { dragDepth++; $("drop-hint").hidden = false; } });
|
|
1098
|
+
document.addEventListener("dragleave", (e) => { if (hasFiles(e) && --dragDepth <= 0) { dragDepth = 0; $("drop-hint").hidden = true; } });
|
|
1099
|
+
document.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); });
|
|
1100
|
+
document.addEventListener("drop", (e) => {
|
|
1101
|
+
if (!hasFiles(e)) return;
|
|
1102
|
+
e.preventDefault(); // never let the browser open the file instead
|
|
1103
|
+
dragDepth = 0;
|
|
1104
|
+
$("drop-hint").hidden = true;
|
|
1105
|
+
attachFiles(e.dataTransfer.files);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
/* ---- Start ------------------------------------------------------------------------------------------- */
|
|
1109
|
+
loadMain();
|
|
1110
|
+
loadMeasure();
|
|
1111
|
+
renderPinTray();
|
|
1112
|
+
connect();
|