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 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 the <section>s inside the element with class "slides" (Reveal.js). */
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
- out.push(c);
157
- visit(c); // vertical stacks
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
- sections.forEach((s) => walk({ children: [s] }, (c) => {
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
- const last = rest.filter((s) => s.parent.name !== "section").at(-1);
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.0",
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
- editChain = editChain.then(async () => {
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
- editChain = editChain.then(async () => {
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
- editChain = editChain.then(async () => {
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() { editChain = editChain.then(() => loadMain()); },
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
- editChain = editChain.then(async () => {
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; // a newer load, or the user started typing
292
- notes = { eid, text: d?.text || "", html: !!d?.html };
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
- editChain = editChain.then(async () => {
312
+ const run = async () => {
301
313
  notesStatus("Saving…");
302
- const res = await api("notes", edit);
303
- const d = await res.json().catch(() => ({}));
304
- if (!res.ok) { notesStatus(); return toast(d.error || "Could not save the notes."); } // the text stays, to save again
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", () => { saveNotes(); loadNotes(); }); // then show the slide we are on now
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(() => { if (state.busy) { session.stop(); } }, 5000); // force if it does not answer
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
  }