pindeck 2.8.0 → 2.8.2
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 +16 -0
- package/lib/eids.mjs +9 -6
- package/lib/platform-notice.mjs +34 -0
- package/package.json +5 -3
- package/public/editor.js +42 -19
- package/server.mjs +9 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# PinDeck
|
|
2
2
|
|
|
3
|
+
> **macOS only.** Windows and Linux are not supported.
|
|
4
|
+
|
|
3
5
|
**Pin it. Say it. Done.** PinDeck is a local editor for HTML slide decks (reveal.js): the slides on
|
|
4
6
|
the left, the slide in the middle, and a chat with Claude Code on the right. You click elements to
|
|
5
7
|
pin them, write what to change, and Claude edits the HTML and CSS. The slide updates in place.
|
|
@@ -26,6 +28,10 @@ checked the layout; the next request, still being typed, refers to the new pins
|
|
|
26
28
|
|
|
27
29
|
## Quick start
|
|
28
30
|
|
|
31
|
+
Requires macOS. The npm package declares `"os": ["darwin"]`, so normal npm installation
|
|
32
|
+
rejects other operating systems. Overriding that check does not make those platforms supported. Running the server from a cloned
|
|
33
|
+
repository on another OS shows a local support notice instead of opening the deck or starting Claude.
|
|
34
|
+
|
|
29
35
|
```bash
|
|
30
36
|
npx pindeck path/to/deck # from npm
|
|
31
37
|
|
|
@@ -249,6 +255,16 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
249
255
|
|
|
250
256
|
## Versions
|
|
251
257
|
|
|
258
|
+
* **2.8.2**: declares macOS-only support in npm metadata and the README to prevent accidental
|
|
259
|
+
installation on unsupported operating systems. Source launches on other platforms show a support
|
|
260
|
+
notice without opening the deck.
|
|
261
|
+
|
|
262
|
+
* **2.8.1**: fixes from a review. Speaker notes that fail to load leave the field empty and locked, instead
|
|
263
|
+
of showing the previous slide's notes and saving your edits over them. A save that fails because the
|
|
264
|
+
server cannot be reached is reported, and the edits after it (drags, text, bullets, notes) are still
|
|
265
|
+
saved instead of being dropped without a word. Stop no longer ends a turn started after it. Slides in
|
|
266
|
+
vertical stacks keep their order and ids, and a slide moved to the end goes after a final stack.
|
|
267
|
+
A first automated test suite (`npm test`).
|
|
252
268
|
* **2.8**: a code review: safer, sturdier, and a PowerPoint import that keeps more. The server answers
|
|
253
269
|
only to `127.0.0.1` and `localhost`, so another website cannot read the editor's token (DNS
|
|
254
270
|
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
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
|
|
3
|
+
export function createPlatformNoticeServer(platform) {
|
|
4
|
+
const windows = platform === 'win32';
|
|
5
|
+
const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck currently requires macOS';
|
|
6
|
+
const explanation = windows
|
|
7
|
+
? 'PinDeck is currently available for macOS only. Windows support is planned for a future version; there is no release date yet.'
|
|
8
|
+
: 'This version of PinDeck supports macOS only. Linux and other operating systems are not supported yet.';
|
|
9
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck · macOS required</title><style>
|
|
10
|
+
:root{font-family:system-ui,sans-serif;color:#243348;background:#f4f6f8;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(100%,620px);padding:40px;background:white;border:1px solid #dde3ea;border-radius:18px;box-shadow:0 12px 40px #20334d0a}.brand{font-weight:700;color:#c36516;margin:0 0 32px}h1{font-size:clamp(26px,5vw,36px);line-height:1.2;margin:0 0 20px}p{line-height:1.65}a{color:#945012}footer{margin-top:28px;font-size:14px;color:#596577}
|
|
11
|
+
</style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can use PinDeck on a Mac or follow updates on <a href="https://github.com/ehabets/PinDeck">GitHub</a>.</p><footer>Close this tab and press Ctrl+C in the terminal to stop this notice.</footer></main></body></html>`;
|
|
12
|
+
return http.createServer((req, res) => {
|
|
13
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { Allow: 'GET, HEAD' }); return res.end(); }
|
|
14
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
|
|
15
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
|
|
16
|
+
res.end(req.method === 'HEAD' ? undefined : html);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Keep startup here until the notice is closed: no deck/state initialization runs on unsupported OSes.
|
|
21
|
+
export async function showPlatformNotice(port, platform = process.platform) {
|
|
22
|
+
const server = createPlatformNoticeServer(platform);
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
const stop = () => server.close();
|
|
25
|
+
server.once('error', reject);
|
|
26
|
+
server.once('close', () => {
|
|
27
|
+
process.off('SIGINT', stop); process.off('SIGTERM', stop); resolve();
|
|
28
|
+
});
|
|
29
|
+
server.listen(port, '127.0.0.1', () => {
|
|
30
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
31
|
+
console.log(`PinDeck requires macOS. Open http://127.0.0.1:${server.address().port}/ for details. Press Ctrl+C to stop.`);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "2.8.
|
|
4
|
-
"description": "PinDeck: a local editor for HTML slide decks. Pin elements, describe the change, and Claude Code edits the slides.",
|
|
3
|
+
"version": "2.8.2",
|
|
4
|
+
"description": "PinDeck: a local macOS editor for HTML slide decks. Pin elements, describe the change, and Claude Code edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
|
7
7
|
"presentation",
|
|
@@ -32,8 +32,10 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
|
-
"start": "node server.mjs"
|
|
35
|
+
"start": "node server.mjs",
|
|
36
|
+
"test": "node --test"
|
|
36
37
|
},
|
|
38
|
+
"os": ["darwin"],
|
|
37
39
|
"engines": {
|
|
38
40
|
"node": ">=20"
|
|
39
41
|
}
|
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
|
@@ -41,6 +41,11 @@ const argv = process.argv.slice(2);
|
|
|
41
41
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
42
42
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
43
43
|
const PORT = +opt("--port", process.env.PORT || 5180);
|
|
44
|
+
if (process.platform !== "darwin") {
|
|
45
|
+
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
46
|
+
await showPlatformNotice(PORT);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
44
49
|
const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
|
|
45
50
|
const DECK_DIR = path.resolve(argv[0] || process.cwd());
|
|
46
51
|
const DECK_FILE = "index.html";
|
|
@@ -964,8 +969,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
964
969
|
}
|
|
965
970
|
if (p === "/api/stop" && req.method === "POST") {
|
|
966
971
|
if (state.busy && session.running) {
|
|
972
|
+
const interruptedTurn = state.turn;
|
|
967
973
|
session.interrupt();
|
|
968
|
-
setTimeout(() => {
|
|
974
|
+
setTimeout(() => {
|
|
975
|
+
if (state.busy && state.turn === interruptedTurn) session.stop();
|
|
976
|
+
}, 5000); // force only if the interrupted turn does not answer
|
|
969
977
|
}
|
|
970
978
|
return json(res, 200, {});
|
|
971
979
|
}
|