pindeck 2.8.0 → 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/README.md +6 -0
- package/lib/eids.mjs +9 -6
- package/package.json +3 -2
- package/public/editor.js +42 -19
- package/server.mjs +4 -1
package/README.md
CHANGED
|
@@ -249,6 +249,12 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
249
249
|
|
|
250
250
|
## Versions
|
|
251
251
|
|
|
252
|
+
* **2.8.1**: fixes from a review. Speaker notes that fail to load leave the field empty and locked, instead
|
|
253
|
+
of showing the previous slide's notes and saving your edits over them. A save that fails because the
|
|
254
|
+
server cannot be reached is reported, and the edits after it (drags, text, bullets, notes) are still
|
|
255
|
+
saved instead of being dropped without a word. Stop no longer ends a turn started after it. Slides in
|
|
256
|
+
vertical stacks keep their order and ids, and a slide moved to the end goes after a final stack.
|
|
257
|
+
A first automated test suite (`npm test`).
|
|
252
258
|
* **2.8**: a code review: safer, sturdier, and a PowerPoint import that keeps more. The server answers
|
|
253
259
|
only to `127.0.0.1` and `localhost`, so another website cannot read the editor's token (DNS
|
|
254
260
|
rebinding). A missing `claude` command ends the turn with a message instead of leaving the chat
|
package/lib/eids.mjs
CHANGED
|
@@ -145,7 +145,7 @@ function find(node, pred) {
|
|
|
145
145
|
return hit;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
/* Slides are
|
|
148
|
+
/* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. */
|
|
149
149
|
export function findSlides(tree) {
|
|
150
150
|
const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
|
|
151
151
|
if (!slidesEl) return [];
|
|
@@ -153,8 +153,8 @@ export function findSlides(tree) {
|
|
|
153
153
|
const visit = (node) => {
|
|
154
154
|
for (const c of node.children) {
|
|
155
155
|
if (c.name !== "section") continue;
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
if (c.children.some((child) => child.name === "section")) visit(c);
|
|
157
|
+
else out.push(c);
|
|
158
158
|
}
|
|
159
159
|
};
|
|
160
160
|
visit(slidesEl);
|
|
@@ -218,10 +218,11 @@ export function assignEids(html) {
|
|
|
218
218
|
|
|
219
219
|
// Keep every existing, non-duplicate id first so new names never collide with them.
|
|
220
220
|
const keep = new Set();
|
|
221
|
-
|
|
221
|
+
// Reserve container ids too, including ids assigned to stacks by older versions.
|
|
222
|
+
walk(tree, (c) => {
|
|
222
223
|
const v = c.attrs?.["data-eid"]?.value;
|
|
223
224
|
if (v && !used.has(v)) { used.add(v); keep.add(c); c.eid = v; }
|
|
224
|
-
})
|
|
225
|
+
});
|
|
225
226
|
|
|
226
227
|
const slides = [];
|
|
227
228
|
sections.forEach((section, i) => {
|
|
@@ -518,7 +519,9 @@ export function moveSlide(html, eid, beforeEid) {
|
|
|
518
519
|
const at = slideBlock(cut, byEid(rest, beforeEid)).start;
|
|
519
520
|
out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
|
|
520
521
|
} else {
|
|
521
|
-
|
|
522
|
+
// A final vertical stack is absent from the leaf-slide list, but we must insert after it.
|
|
523
|
+
const last = find(parse(cut), (c) => classesOf(c).includes("slides"))
|
|
524
|
+
.children.filter((c) => c.name === "section").at(-1);
|
|
522
525
|
const at = elementEnd(cut, last);
|
|
523
526
|
out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
|
|
524
527
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "PinDeck: a local editor for HTML slide decks. Pin elements, describe the change, and Claude Code edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
|
-
"start": "node server.mjs"
|
|
35
|
+
"start": "node server.mjs",
|
|
36
|
+
"test": "node --test"
|
|
36
37
|
},
|
|
37
38
|
"engines": {
|
|
38
39
|
"node": ">=20"
|
package/public/editor.js
CHANGED
|
@@ -100,28 +100,28 @@ window.__editorHost = {
|
|
|
100
100
|
return d.html;
|
|
101
101
|
},
|
|
102
102
|
commitText(edit) {
|
|
103
|
-
|
|
103
|
+
queueEdit(async () => {
|
|
104
104
|
const res = await api("text", edit);
|
|
105
105
|
if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not save the text.");
|
|
106
106
|
loadMain(); // re-render: math, footer numbers, and the file as saved
|
|
107
|
-
});
|
|
107
|
+
}, "Could not save the text.");
|
|
108
108
|
},
|
|
109
109
|
commitSplit(op, done) {
|
|
110
|
-
|
|
110
|
+
queueEdit(async () => {
|
|
111
111
|
const res = await api("split", op);
|
|
112
112
|
const d = await res.json().catch(() => ({}));
|
|
113
113
|
if (!res.ok) { toast(d.error || "Could not add the bullet."); loadMain(); return; }
|
|
114
114
|
done?.(d.eid);
|
|
115
|
-
});
|
|
115
|
+
}, "Could not add the bullet.");
|
|
116
116
|
},
|
|
117
117
|
commitRemove(eid) {
|
|
118
|
-
|
|
118
|
+
queueEdit(async () => {
|
|
119
119
|
const res = await api("remove", { eid });
|
|
120
120
|
if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not remove the bullet.");
|
|
121
121
|
loadMain();
|
|
122
|
-
});
|
|
122
|
+
}, "Could not remove the bullet.");
|
|
123
123
|
},
|
|
124
|
-
refresh() {
|
|
124
|
+
refresh() { queueEdit(() => loadMain(), "Could not reload the slide."); },
|
|
125
125
|
editingChanged(on) { state.editing = on; },
|
|
126
126
|
requestDelete(eid) { requestDelete(eid); },
|
|
127
127
|
dropFiles(files, at) { attachFiles(files, at); },
|
|
@@ -131,15 +131,20 @@ window.__editorHost = {
|
|
|
131
131
|
|
|
132
132
|
/* ---- Hand edits (drag, resize, nudge) ---------------------------------------------------------- */
|
|
133
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
|
+
}
|
|
134
140
|
function postEdit(op, label) {
|
|
135
|
-
|
|
141
|
+
return queueEdit(async () => {
|
|
136
142
|
const res = await api("edit", { ops: [op], label });
|
|
137
143
|
if (!res.ok) {
|
|
138
144
|
toast((await res.json().catch(() => ({}))).error || "Could not save the change.");
|
|
139
145
|
loadMain(); // show the file as it is
|
|
140
146
|
}
|
|
141
147
|
});
|
|
142
|
-
return editChain;
|
|
143
148
|
}
|
|
144
149
|
function flushNudge() {
|
|
145
150
|
clearTimeout(nudgeTimer);
|
|
@@ -285,29 +290,47 @@ async function loadNotes() {
|
|
|
285
290
|
if (!eid || document.activeElement === notesBox) return;
|
|
286
291
|
const seq = ++notesSeq;
|
|
287
292
|
await editChain.catch(() => {}); // a save still on its way would otherwise be read back as the old text
|
|
288
|
-
if (seq !== notesSeq) return;
|
|
293
|
+
if (seq !== notesSeq || notesDirty()) return;
|
|
289
294
|
const res = await api(`notes?${new URLSearchParams({ eid })}`).catch(() => null);
|
|
290
295
|
const d = res?.ok ? await res.json().catch(() => null) : null;
|
|
291
|
-
if (seq !== notesSeq || document.activeElement === notesBox) return;
|
|
292
|
-
|
|
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 };
|
|
293
304
|
notesBox.value = notes.text;
|
|
305
|
+
notesBox.disabled = false;
|
|
294
306
|
notesStatus();
|
|
295
307
|
}
|
|
296
308
|
function saveNotes() {
|
|
297
|
-
if (!notesDirty()) return;
|
|
309
|
+
if (!notesDirty()) return Promise.resolve(true);
|
|
298
310
|
const edit = { eid: notes.eid, text: notesBox.value, html: notes.html };
|
|
299
311
|
flushNudge();
|
|
300
|
-
|
|
312
|
+
const run = async () => {
|
|
301
313
|
notesStatus("Saving…");
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
}
|
|
305
324
|
if (notes.eid === edit.eid) notes.text = edit.text;
|
|
306
325
|
notesStatus("Saved");
|
|
307
326
|
setTimeout(() => { if ($("notes-status").textContent === "Saved") notesStatus(); }, 1500);
|
|
308
|
-
|
|
327
|
+
return true;
|
|
328
|
+
};
|
|
329
|
+
return queueEdit(run, "Could not save the notes.");
|
|
309
330
|
}
|
|
310
|
-
notesBox.addEventListener("blur", () => {
|
|
331
|
+
notesBox.addEventListener("blur", async () => {
|
|
332
|
+
if (await saveNotes()) loadNotes(); // retain a failed draft and its original slide id for retry
|
|
333
|
+
});
|
|
311
334
|
notesBox.addEventListener("keydown", (e) => {
|
|
312
335
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); notesBox.blur(); }
|
|
313
336
|
else if (e.key === "Escape") { e.preventDefault(); notesBox.value = notes.text; notesBox.blur(); }
|
package/server.mjs
CHANGED
|
@@ -964,8 +964,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
964
964
|
}
|
|
965
965
|
if (p === "/api/stop" && req.method === "POST") {
|
|
966
966
|
if (state.busy && session.running) {
|
|
967
|
+
const interruptedTurn = state.turn;
|
|
967
968
|
session.interrupt();
|
|
968
|
-
setTimeout(() => {
|
|
969
|
+
setTimeout(() => {
|
|
970
|
+
if (state.busy && state.turn === interruptedTurn) session.stop();
|
|
971
|
+
}, 5000); // force only if the interrupted turn does not answer
|
|
969
972
|
}
|
|
970
973
|
return json(res, 200, {});
|
|
971
974
|
}
|