pindeck 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -9
- package/front.mjs +3 -2
- package/lib/capabilities.mjs +13 -0
- package/lib/claude.mjs +1 -1
- package/lib/eids.mjs +1 -1
- package/lib/login.mjs +11 -2
- package/lib/system-prompt.md +2 -2
- package/package.json +1 -1
- package/public/editor.css +7 -4
- package/public/editor.js +58 -18
- package/public/index.html +3 -2
- package/server.mjs +18 -14
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PinDeck
|
|
2
2
|
|
|
3
|
-
> **macOS and Linux · Version 3** (`3.
|
|
3
|
+
> **macOS and Linux · Version 3** (`3.4.0`). On Linux, PinDeck runs as a headless server that you use
|
|
4
4
|
> from your Mac's browser through an SSH tunnel (see [Running on a Linux server](#running-on-a-linux-server)).
|
|
5
5
|
> Windows is not supported. Coming from PinDeck 2: version 3 keeps its settings and chats in
|
|
6
6
|
> `~/.pindeck-v3`, apart from 2.x (see [Models, providers and configuration](#models-providers-and-configuration)).
|
|
@@ -328,7 +328,7 @@ ssh -N -T -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCo
|
|
|
328
328
|
|
|
329
329
|
Open the login link PinDeck printed on the server in the Mac's browser. The local port may differ from
|
|
330
330
|
the server's: with `-L 127.0.0.1:8080:127.0.0.1:5180`, change 5180 in the link to 8080. PinDeck accepts any
|
|
331
|
-
port, as long as the address is `127.0.0.1
|
|
331
|
+
port, as long as the address is `127.0.0.1`: `localhost` is where decks are presented, and nobody logs in there. If the tunnel drops, a message you have already sent keeps running on the
|
|
332
332
|
server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
|
|
333
333
|
message twice. If PinDeck restarts, the editor asks you to log in again: run `node front.mjs login
|
|
334
334
|
path/to/deck` on the server for a new link.
|
|
@@ -391,7 +391,7 @@ show and commit as usual. Clones without the filter configured simply see the fi
|
|
|
391
391
|
| Stop | **Stop** interrupts the assistant; the conversation continues. **New chat** starts a new conversation; your slides, Undo and Redo stay as they are. |
|
|
392
392
|
| New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides; a new content slide or divider opens its title for typing. |
|
|
393
393
|
| Import from PowerPoint | **+ New slide** → **Import from PowerPoint…**, then choose a `.pptx` file and tick the slides you want (hidden PowerPoint slides start unticked). They are added after the current slide in this deck's own layouts, and the assistant can improve the complex ones; see [PowerPoint import](#powerpoint-import). `⌘Z` removes them again, with their figures. |
|
|
394
|
-
| Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes
|
|
394
|
+
| Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes show their formatting: `⌘B`, `⌘I` and `⌘U` format the selection, `Enter` starts a new line, and pasted text comes in plain. Notes with markup the field cannot show safely (other tags, styles, scripts) are edited as HTML. Drag the line above the notes to give them more or less room (double-click it for the default size); collapse them with their header. |
|
|
395
395
|
| Move a slide | Drag it up or down in the slide list; an orange line shows where it will go. It moves with its notes and the comment above it, and `⌘Z` puts it back. Slides inside vertical stacks cannot be moved yet. |
|
|
396
396
|
| Hide a slide | The eye on a slide in the list, or `H`. A hidden slide stays in the deck and in PinDeck (greyed out, with a banner) but is skipped when presenting and in PDFs; **Export ▾** can include hidden slides. Show it the same way. |
|
|
397
397
|
| Model and effort | Choose a discovered Claude or OpenAI model in the chat header, then use the separate reasoning-effort selector. Provider changes offer an optional handover; same-provider changes continue the conversation. The choice is saved in `~/.pindeck-v3/config.json`. |
|
|
@@ -457,10 +457,13 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
457
457
|
only six connections to one port; if a deck server stops, the front opens the deck again and the tab
|
|
458
458
|
catches up. Each PinDeck process notes its address in `~/.pindeck-v3/servers/` for `pindeck login` and
|
|
459
459
|
`cli.mjs`, and a deck server holds its deck's `open.json`, so a deck opens in one PinDeck only.
|
|
460
|
-
* **Deck pages are sandboxed.** The deck page
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
460
|
+
* **Deck pages are sandboxed.** The deck page in the editor's frames runs with a sandbox policy in an
|
|
461
|
+
origin of its own, at a per-run address that serves the deck's files and nothing else. A script in a
|
|
462
|
+
deck, a template, or written by the assistant cannot reach the login cookie, the API, the chat or other
|
|
463
|
+
decks. The editor and its bridge in the slide frame talk by `postMessage`. Present opens the same
|
|
464
|
+
address on `localhost` without the sandbox, so reveal.js's speaker view (`S`) works: browsers treat
|
|
465
|
+
`localhost` and `127.0.0.1` as different sites, PinDeck takes no login and no cookie there, and the tab is
|
|
466
|
+
opened without a handle on the editor. Headless Chrome
|
|
464
467
|
(screenshots, layout, PDFs) does not log in at all; the page it asks for is served without the sandbox,
|
|
465
468
|
which its screenshot and print modes cannot handle, only with a key made for that one run.
|
|
466
469
|
* **Element ids.** On start, the server gives every slide and every visible element a readable
|
|
@@ -551,13 +554,22 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
551
554
|
* The undo history lives on the server and starts fresh when PinDeck restarts, or when a deck closes after
|
|
552
555
|
30 minutes without an open editor.
|
|
553
556
|
* One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
|
|
554
|
-
* A deck's own scripts run sandboxed in the editor
|
|
555
|
-
|
|
557
|
+
* A deck's own scripts run sandboxed in the editor: they cannot use `localStorage`, cookies or the editor
|
|
558
|
+
page. A deck that needs them for its slides to work should catch the error. In Present they can, on
|
|
559
|
+
`localhost`, where every deck of one PinDeck shares the same `localStorage`.
|
|
556
560
|
* Linux: pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
|
|
557
561
|
Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server)).
|
|
558
562
|
|
|
559
563
|
## Versions
|
|
560
564
|
|
|
565
|
+
* **3.4.0**: formatted speaker notes, and the speaker view works.
|
|
566
|
+
- The Notes field shows the notes' formatting: `⌘B`, `⌘I` and `⌘U` format the selection, `Enter` starts
|
|
567
|
+
a new line, and pasted text comes in plain. Notes with markup the field cannot show safely are still
|
|
568
|
+
edited as HTML, and nothing in them is lost on save.
|
|
569
|
+
- Present opens the deck on localhost, so reveal.js's speaker view (`S`) shows the notes and the next slide.
|
|
570
|
+
- Claude runs PinDeck's commands by this server's path when the deck's `CLAUDE.md` names another
|
|
571
|
+
(such as `node ../pindeck/cli.mjs`), and a chat that used an old path is told the right one.
|
|
572
|
+
|
|
561
573
|
* **3.3.0**: fixes from a code audit.
|
|
562
574
|
- After a PowerPoint import, refining the slides no longer deletes pictures that the deck still uses
|
|
563
575
|
(when the turn was stopped, failed or only changed `theme.css`); Undo brings removed pictures back.
|
package/front.mjs
CHANGED
|
@@ -150,7 +150,8 @@ setInterval(async () => {
|
|
|
150
150
|
streamed back (event streams, media ranges and large uploads included) */
|
|
151
151
|
function proxy(req, res, d, rest, anonymous = false) {
|
|
152
152
|
d.last = Date.now();
|
|
153
|
-
|
|
153
|
+
// the deck's pages keep the browser's host: on localhost (Present) the deck server leaves out the sandbox
|
|
154
|
+
const headers = { ...req.headers, host: anonymous ? req.headers.host : new URL(d.url).host, ...(anonymous ? {} : { "x-editor-token": d.token }) };
|
|
154
155
|
if (anonymous) delete headers["x-editor-token"];
|
|
155
156
|
delete headers.cookie;
|
|
156
157
|
delete headers.origin; // checked here against the front's address; the deck server checks its own
|
|
@@ -287,7 +288,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
287
288
|
try {
|
|
288
289
|
const url = new URL(req.url, ORIGIN);
|
|
289
290
|
const p = url.pathname;
|
|
290
|
-
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
291
|
+
if (req.method === "GET" && p === "/login") return login(res, url, host);
|
|
291
292
|
// the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
|
|
292
293
|
if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
|
|
293
294
|
/* A deck's pages and files under /d/<deck>/v/<token>/: asked for by the deck itself, which runs sandboxed
|
package/lib/capabilities.mjs
CHANGED
|
@@ -119,3 +119,16 @@ export function cliCommand(rel) {
|
|
|
119
119
|
if (!/["$`\\!]/.test(rel)) return `node "${rel}"`;
|
|
120
120
|
return `node '${rel.replaceAll("'", `'\\''`)}'`;
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
/* A deck's CLAUDE.md names the CLI by where PinDeck sat when it was written (`node ../cli.mjs`,
|
|
124
|
+
`node ../pindeck/cli.mjs`). Claude may run only this server's command, so every one outside the deck (../) becomes it. */
|
|
125
|
+
export function useCliCommand(text, cmd) {
|
|
126
|
+
return text.replace(/node (?:\.\.\/)+(?:[\w.@-]+\/)*cli\.mjs\b/g, () => cmd);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Why a refused command was refused, when it is PinDeck's CLI under another path (one Claude saw earlier
|
|
130
|
+
in a resumed conversation, from where PinDeck sat then): the same command as it must be run. Else null. */
|
|
131
|
+
export function cliRefusal(command, cmd) {
|
|
132
|
+
const fixed = useCliCommand(String(command ?? ""), cmd);
|
|
133
|
+
return fixed !== String(command ?? "") ? `Refused: PinDeck's command is \`${cmd}\` now. Run \`${fixed}\` instead.` : null;
|
|
134
|
+
}
|
package/lib/claude.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export class ClaudeSession {
|
|
|
33
33
|
onMessage: ev => {
|
|
34
34
|
if (this.transport !== transport) return;
|
|
35
35
|
if (ev.type === 'control_request') { transport.write({ type: 'control_response', response: { subtype: 'success', request_id: ev.request_id,
|
|
36
|
-
response: { behavior: 'deny', message: 'This tool is outside PinDeck permissions.' } } }); return; }
|
|
36
|
+
response: { behavior: 'deny', message: this.denyMessage?.(ev.request) || 'This tool is outside PinDeck permissions.' } } }); return; }
|
|
37
37
|
if (ev.session_id) this.sessionId = ev.session_id;
|
|
38
38
|
this.onEvent?.(ev);
|
|
39
39
|
},
|
package/lib/eids.mjs
CHANGED
|
@@ -122,7 +122,7 @@ export function parse(html) {
|
|
|
122
122
|
const classesOf = (node) => (node.attrs.class?.value || "").split(/\s+/).filter(Boolean);
|
|
123
123
|
|
|
124
124
|
const ENTITIES = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
125
|
-
function decode(s) { // one pass, so "&lt;" stays "<"
|
|
125
|
+
export function decode(s) { // one pass, so "&lt;" stays "<"
|
|
126
126
|
return s.replace(/&(?:(nbsp|amp|lt|gt|quot|apos)|#(\d+)|#x([0-9a-f]+));/gi, (m, name, d, x) =>
|
|
127
127
|
name ? ENTITIES[name.toLowerCase()] : String.fromCodePoint(d ? +d : parseInt(x, 16)));
|
|
128
128
|
}
|
package/lib/login.mjs
CHANGED
|
@@ -11,6 +11,10 @@ import { execFile } from "node:child_process";
|
|
|
11
11
|
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
12
12
|
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
13
13
|
export const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
|
|
14
|
+
/* localhost is where decks are presented, without the sandbox (reveal.js's speaker view needs a real origin):
|
|
15
|
+
no login there, and no cookie counts, so a deck's script on it gets nothing it could not already read.
|
|
16
|
+
Browsers treat localhost and 127.0.0.1 as different sites, so the login cookie of 127.0.0.1 never goes there. */
|
|
17
|
+
export const presentHost = (h) => /^localhost(:\d{1,5})?$/.test(String(h || "").toLowerCase());
|
|
14
18
|
|
|
15
19
|
export const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
16
20
|
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
@@ -40,10 +44,15 @@ export function createLogin({ token, port, origin }) {
|
|
|
40
44
|
const cookieName = () => `pindeck-${port()}`;
|
|
41
45
|
const sameSecret = (a) => typeof a === "string" && a.length === token.length &&
|
|
42
46
|
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(token));
|
|
43
|
-
const authed = (req) => sameSecret(req.headers["x-editor-token"]) ||
|
|
47
|
+
const authed = (req) => sameSecret(req.headers["x-editor-token"]) ||
|
|
48
|
+
(!presentHost(req.headers.host) && sameSecret(cookie(req, cookieName())));
|
|
44
49
|
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
45
50
|
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
46
|
-
function login(res, url) {
|
|
51
|
+
function login(res, url, host) {
|
|
52
|
+
if (presentHost(host)) { // logins happen on 127.0.0.1 only: the same link there, its code still unused
|
|
53
|
+
res.writeHead(302, { location: `http://127.0.0.1${host.slice(9)}${url.pathname}${url.search}`, "cache-control": "no-store" });
|
|
54
|
+
return res.end();
|
|
55
|
+
}
|
|
47
56
|
const code = url.searchParams.get("code") || "";
|
|
48
57
|
const exp = loginCodes.get(code);
|
|
49
58
|
loginCodes.delete(code);
|
package/lib/system-prompt.md
CHANGED
|
@@ -64,8 +64,8 @@ user asks for: facts, numbers and wording for slides, or a figure.
|
|
|
64
64
|
- Never put the PDF itself on a slide or link it from the deck.
|
|
65
65
|
|
|
66
66
|
## Commands
|
|
67
|
-
The only shell commands you can run here are the PinDeck commands below ({{CLI}} ...),
|
|
68
|
-
its own: no scripts, heredocs, temporary files, loops, variables or `&&` chains, and no python, sips,
|
|
67
|
+
The only shell commands you can run here are the PinDeck commands below ({{CLI}} ...), with exactly
|
|
68
|
+
this path to cli.mjs even if another one appears earlier in the conversation, each on its own: no scripts, heredocs, temporary files, loops, variables or `&&` chains, and no python, sips,
|
|
69
69
|
convert or ls. Such commands are refused (or need an approval nobody can give) and are not run. A
|
|
70
70
|
refused command did nothing: do not retry it as it is, do it another way.
|
|
71
71
|
- To change files, use Edit, even for large changes: several Edit calls in a row are fine.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
package/public/editor.css
CHANGED
|
@@ -183,12 +183,15 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
|
|
|
183
183
|
.notes-head:hover { background: var(--bg); border: 0; }
|
|
184
184
|
.notes-head .caret { display: inline-block; transition: transform .15s; }
|
|
185
185
|
.notes-panel.collapsed .caret { transform: rotate(-90deg); }
|
|
186
|
-
.notes-panel.collapsed
|
|
186
|
+
.notes-panel.collapsed .notes-box { display: none; }
|
|
187
187
|
.notes-status { margin-left: auto; font-weight: 400; text-transform: none; letter-spacing: 0; }
|
|
188
|
-
.notes-
|
|
188
|
+
.notes-box { height: 96px; overflow-y: auto; box-sizing: border-box; border: 0; border-top: 1px solid var(--line);
|
|
189
189
|
padding: 8px 14px; font: inherit; font-size: 13.5px; line-height: 1.45; color: var(--ink); background: var(--panel); outline: none; }
|
|
190
|
-
.notes-
|
|
191
|
-
.notes-
|
|
190
|
+
.notes-box:focus { background: #fffcf6; }
|
|
191
|
+
.notes-box:empty::before { content: attr(data-placeholder); color: var(--muted); }
|
|
192
|
+
.notes-box[contenteditable="false"] { color: var(--muted); }
|
|
193
|
+
.notes-box[contenteditable="plaintext-only"] { white-space: pre-wrap; font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; }
|
|
194
|
+
.notes-box p, .notes-box ul, .notes-box ol { margin: 0 0 .5em; }
|
|
192
195
|
|
|
193
196
|
/* ---- Chat ---- */
|
|
194
197
|
.chat-pane { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--line); background: var(--panel); }
|
package/public/editor.js
CHANGED
|
@@ -365,16 +365,44 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
|
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
/* ---- Speaker notes: the field under the slide -------------------------------------------------------
|
|
368
|
-
Shows the current slide's <aside class="notes">
|
|
369
|
-
or on ⌘Enter; Esc puts back what the file says.
|
|
370
|
-
|
|
368
|
+
Shows the current slide's <aside class="notes"> as formatted text (⌘B ⌘I ⌘U; Enter is a line break) and
|
|
369
|
+
saves its HTML as a hand edit when the field loses focus or on ⌘Enter; Esc puts back what the file says.
|
|
370
|
+
Notes with markup the field cannot show safely (other tags or attributes) are edited as their HTML
|
|
371
|
+
("HTML" in the header). Typing is never overwritten by a reload. */
|
|
372
|
+
const NOTE_TAGS = new Set(["P", "BR", "B", "STRONG", "I", "EM", "U", "S", "SUB", "SUP", "CODE", "SPAN", "SMALL", "MARK",
|
|
373
|
+
"UL", "OL", "LI", "A", "BLOCKQUOTE", "DIV"]);
|
|
374
|
+
const safeNoteAttr = (el, a) => a.name === "class" || (a.name === "href" && el.tagName === "A" && /^(https?:|mailto:|#)/i.test(a.value.trim()));
|
|
375
|
+
/* The notes' HTML when the field can show it unchanged (only harmless tags and attributes), else null. Parsed inert. */
|
|
376
|
+
function richNotes(html) {
|
|
377
|
+
const body = new DOMParser().parseFromString("<body>" + html, "text/html").body; // <body>: a leading <style> or <script> stays in it, to be seen
|
|
378
|
+
for (const el of body.querySelectorAll("*"))
|
|
379
|
+
if (!NOTE_TAGS.has(el.tagName) || [...el.attributes].some((a) => !safeNoteAttr(el, a))) return null;
|
|
380
|
+
return body.innerHTML;
|
|
381
|
+
}
|
|
371
382
|
const notesBox = $("notes");
|
|
372
|
-
let notes = { eid: null, text: "", html: false }; // the
|
|
383
|
+
let notes = { eid: null, text: "", html: false }; // the field's content as loaded; html: edited as raw HTML
|
|
373
384
|
let notesSeq = 0;
|
|
374
|
-
const
|
|
385
|
+
const notesValue = () => notes.html ? notesBox.textContent : notesBox.innerHTML;
|
|
386
|
+
const notesDirty = () => !!notes.eid && notesValue().trim() !== notes.text.trim();
|
|
387
|
+
/* What is saved: the raw HTML as typed, or the field's HTML without the browser's own styling of edits */
|
|
388
|
+
function notesHtml() {
|
|
389
|
+
if (notes.html) return notesBox.textContent;
|
|
390
|
+
if (!notesBox.textContent.trim()) return "";
|
|
391
|
+
const c = notesBox.cloneNode(true);
|
|
392
|
+
c.querySelectorAll("[style]").forEach((el) => el.removeAttribute("style"));
|
|
393
|
+
return c.innerHTML.replace(/(\s*<br>)+\s*$/, "");
|
|
394
|
+
}
|
|
395
|
+
/* Shows the notes `d` ({ text, html } from the server, or null: none) of slide `eid` */
|
|
396
|
+
function showNotes(eid, d) {
|
|
397
|
+
const rich = !d ? "" : d.html ? richNotes(d.text || "") : (d.text || "").split("\n").map(esc).join("<br>");
|
|
398
|
+
notes = { eid, text: "", html: rich == null };
|
|
399
|
+
notesBox.contentEditable = !d ? "false" : notes.html ? "plaintext-only" : "true";
|
|
400
|
+
if (notes.html) notesBox.textContent = d.text; else notesBox.innerHTML = rich;
|
|
401
|
+
notes.text = notesValue();
|
|
402
|
+
}
|
|
375
403
|
function notesStatus(msg) {
|
|
376
404
|
$("notes-status").textContent = msg ?? (notes.html ? "HTML" : "");
|
|
377
|
-
$("notes-status").title = msg ? "" : notes.html ? "These notes contain
|
|
405
|
+
$("notes-status").title = msg ? "" : notes.html ? "These notes contain markup the field cannot show, so you edit them as HTML" : "";
|
|
378
406
|
}
|
|
379
407
|
async function loadNotes() {
|
|
380
408
|
const eid = state.slides[state.current]?.eid;
|
|
@@ -386,19 +414,15 @@ async function loadNotes() {
|
|
|
386
414
|
const d = res?.ok ? await res.json().catch(() => null) : null;
|
|
387
415
|
if (seq !== notesSeq || document.activeElement === notesBox || notesDirty()) return;
|
|
388
416
|
if (!d) { // detach from the previous slide, so its notes are not shown, or saved over, as this one's
|
|
389
|
-
|
|
390
|
-
notesBox.value = "";
|
|
391
|
-
notesBox.disabled = true;
|
|
417
|
+
showNotes(null, null);
|
|
392
418
|
return notesStatus("Could not load");
|
|
393
419
|
}
|
|
394
|
-
|
|
395
|
-
notesBox.value = notes.text;
|
|
396
|
-
notesBox.disabled = false;
|
|
420
|
+
showNotes(eid, d);
|
|
397
421
|
notesStatus();
|
|
398
422
|
}
|
|
399
423
|
function saveNotes() {
|
|
400
424
|
if (!notesDirty()) return Promise.resolve(true);
|
|
401
|
-
const edit = { eid: notes.eid, text:
|
|
425
|
+
const edit = { eid: notes.eid, text: notesHtml(), html: true }, shown = notesValue();
|
|
402
426
|
flushNudge();
|
|
403
427
|
const run = async () => {
|
|
404
428
|
notesStatus("Saving…");
|
|
@@ -412,7 +436,7 @@ function saveNotes() {
|
|
|
412
436
|
toast(d?.error || "Could not save the notes.");
|
|
413
437
|
return false;
|
|
414
438
|
}
|
|
415
|
-
if (notes.eid === edit.eid) notes.text =
|
|
439
|
+
if (notes.eid === edit.eid) notes.text = shown;
|
|
416
440
|
notesStatus("Saved");
|
|
417
441
|
setTimeout(() => { if ($("notes-status").textContent === "Saved") notesStatus(); }, 1500);
|
|
418
442
|
return true;
|
|
@@ -424,12 +448,23 @@ notesBox.addEventListener("blur", async () => {
|
|
|
424
448
|
});
|
|
425
449
|
notesBox.addEventListener("keydown", (e) => {
|
|
426
450
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); notesBox.blur(); }
|
|
427
|
-
else if (e.key === "Escape") {
|
|
451
|
+
else if (e.key === "Escape") {
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
if (notes.html) notesBox.textContent = notes.text; else notesBox.innerHTML = notes.text;
|
|
454
|
+
notesBox.blur();
|
|
455
|
+
}
|
|
456
|
+
// a <br>, as the notes have them, not a new <div>
|
|
457
|
+
else if (e.key === "Enter" && !notes.html) { e.preventDefault(); document.execCommand("insertLineBreak"); }
|
|
458
|
+
});
|
|
459
|
+
notesBox.addEventListener("paste", (e) => { // pasted text comes in plain: no styles or markup from elsewhere
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
|
|
428
462
|
});
|
|
463
|
+
notesBox.addEventListener("input", () => { if (notesBox.innerHTML === "<br>") notesBox.innerHTML = ""; }); // placeholder again
|
|
429
464
|
// closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
|
|
430
465
|
addEventListener("pagehide", () => {
|
|
431
466
|
if (notesDirty() && !state.busy)
|
|
432
|
-
navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text:
|
|
467
|
+
navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesHtml(), html: true })]));
|
|
433
468
|
});
|
|
434
469
|
function setNotesOpen(open) {
|
|
435
470
|
$("notes-panel").classList.toggle("collapsed", !open);
|
|
@@ -580,8 +615,13 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
580
615
|
}
|
|
581
616
|
});
|
|
582
617
|
|
|
618
|
+
/* Present on localhost, at the deck's view path: no login there, so no sandbox, and reveal.js's speaker view (S)
|
|
619
|
+
works. noopener: the deck's scripts get no handle on this editor. */
|
|
583
620
|
$("present").onclick = () => {
|
|
584
|
-
|
|
621
|
+
const view = state.status?.view || "";
|
|
622
|
+
const u = new URL(`${BASE}${view}deck/${state.slides[state.current]?.hash || ""}`, location.href);
|
|
623
|
+
if (view) u.hostname = "localhost";
|
|
624
|
+
window.open(u, "_blank", "noopener");
|
|
585
625
|
};
|
|
586
626
|
|
|
587
627
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -852,7 +892,7 @@ function handleKey(k, mod = {}) {
|
|
|
852
892
|
else if (k === "ArrowRight" || k === "ArrowDown" || k === "PageDown" || k === " ") goto(state.current + 1);
|
|
853
893
|
}
|
|
854
894
|
document.addEventListener("keydown", (e) => {
|
|
855
|
-
if (e.target.closest?.("input, textarea, dialog")) return;
|
|
895
|
+
if (e.target.closest?.("input, textarea, dialog, [contenteditable]")) return;
|
|
856
896
|
// a menu and the button that opens it keep Enter, Space and the arrow keys (a menu opens, an item is chosen):
|
|
857
897
|
// no slide change, no jump to the chat. Other buttons do not: after a click on ‹ › the focus stays there
|
|
858
898
|
// while the slide is clicked (the slide frame keeps it), and Enter must still edit the selected text.
|
package/public/index.html
CHANGED
|
@@ -71,8 +71,9 @@
|
|
|
71
71
|
title="Drag to resize the notes (double-click: default size)" aria-label="Resize the speaker notes"></div>
|
|
72
72
|
<button id="notes-toggle" class="notes-head" aria-expanded="true" aria-controls="notes" title="Show or hide the speaker notes">
|
|
73
73
|
<span class="caret" aria-hidden="true">▾</span> Notes <span id="notes-status" class="notes-status"></span></button>
|
|
74
|
-
<
|
|
75
|
-
|
|
74
|
+
<div id="notes" class="notes-box" contenteditable="false" role="textbox" aria-multiline="true" spellcheck="true"
|
|
75
|
+
aria-label="Speaker notes of this slide"
|
|
76
|
+
data-placeholder="Speaker notes for this slide, shown in the speaker view (S) when presenting. ⌘B bold, ⌘I italic, ⌘U underline. Saved when you click elsewhere or press ⌘Enter; Esc undoes your typing."></div>
|
|
76
77
|
</section>
|
|
77
78
|
</main>
|
|
78
79
|
|
package/server.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
import crypto from "node:crypto";
|
|
24
24
|
import { spawn, execFile } from "node:child_process";
|
|
25
25
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
-
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
26
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd, decode } from "./lib/eids.mjs";
|
|
27
27
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
28
28
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
29
29
|
import { CodexSession } from "./lib/codex.mjs";
|
|
@@ -34,7 +34,7 @@ import { printLayout, printPdf, chromeReason, ROOT_ARGS, PDF_HEAD_TAG, PDF_PAGE_
|
|
|
34
34
|
import { readPptx } from "./lib/pptx.mjs";
|
|
35
35
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
36
36
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
37
|
-
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
|
|
37
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, useCliCommand, cliRefusal, findExecutable } from "./lib/capabilities.mjs";
|
|
38
38
|
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
39
39
|
import { readInstructions } from "./lib/instructions.mjs";
|
|
40
40
|
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
@@ -42,7 +42,7 @@ import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
|
|
|
42
42
|
import { listDecks, linkProblem } from "./lib/deck-list.mjs";
|
|
43
43
|
import { assetRoots } from "./lib/asset-roots.mjs";
|
|
44
44
|
import { registerServer, printLogin, alive } from "./lib/servers.mjs";
|
|
45
|
-
import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
45
|
+
import { goodHost, presentHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
46
46
|
import { USAGE } from "./lib/usage.mjs";
|
|
47
47
|
|
|
48
48
|
/* ---- Config ------------------------------------------------------------------ */
|
|
@@ -543,7 +543,7 @@ let session = null, starting = null;
|
|
|
543
543
|
const status = () => ({ busy: state.busy, switching: state.switching, preparing: !!state.preparing, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
544
544
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
545
545
|
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
546
|
-
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
546
|
+
capabilities: publicCapabilities(CAPS), rev: deckRev(), view: VIEW.slice(1) + "/",
|
|
547
547
|
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)) });
|
|
548
548
|
/* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
|
|
549
549
|
changed since the last push: the editor keeps the last ones it got. */
|
|
@@ -625,12 +625,12 @@ function othersWorkedSince(start) {
|
|
|
625
625
|
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
626
626
|
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
627
627
|
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
628
|
-
not loaded; a deck imports shared rules explicitly.
|
|
629
|
-
|
|
628
|
+
not loaded; a deck imports shared rules explicitly. The CLI path written there becomes this server's
|
|
629
|
+
command (useCliCommand). */
|
|
630
630
|
function deckInstructions() {
|
|
631
631
|
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
632
632
|
const s = readInstructions(path.join(DECK_DIR, name));
|
|
633
|
-
return s ? `\n\n## The deck's ${name}\n${s
|
|
633
|
+
return s ? `\n\n## The deck's ${name}\n${useCliCommand(s, CLI_CMD)}\n` : "";
|
|
634
634
|
}).join("");
|
|
635
635
|
}
|
|
636
636
|
// a function: ORIGIN is known only once the port is bound
|
|
@@ -668,9 +668,11 @@ function makeSession(choice) {
|
|
|
668
668
|
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
669
669
|
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
670
670
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
671
|
+
// a tool the rules do not allow comes to PinDeck (--permission-prompt-tool stdio), always refused: with the right command if it was the CLI
|
|
672
|
+
denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
|
|
671
673
|
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
672
674
|
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
673
|
-
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
675
|
+
"--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
674
676
|
"--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
|
|
675
677
|
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
676
678
|
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
@@ -1467,9 +1469,10 @@ let ready = false;
|
|
|
1467
1469
|
const NEEDS_DECK = /^\/api\/(message|slides|layout|shot|pdf|pdf-figure|edit|text|split|remove|notes|delete|delete-info|source|slide-hidden|move-slide|add-slide|import-apply|export)$/;
|
|
1468
1470
|
const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
|
|
1469
1471
|
/* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
|
|
1470
|
-
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1472
|
+
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1473
|
+
On localhost (Present) there is no sandbox: no login works there (see presentHost). */
|
|
1471
1474
|
const SCRIPTABLE = /\.(html|svg|xml)$/i;
|
|
1472
|
-
const deckHeaders = (f, viewing) => ({ ...(SCRIPTABLE.test(f) ? { "content-security-policy": SANDBOX } : {}),
|
|
1475
|
+
const deckHeaders = (f, viewing, open) => ({ ...(SCRIPTABLE.test(f) && !open ? { "content-security-policy": SANDBOX } : {}),
|
|
1473
1476
|
...(viewing ? { "access-control-allow-origin": "*" } : {}) });
|
|
1474
1477
|
const server = http.createServer(async (req, res) => {
|
|
1475
1478
|
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
@@ -1478,7 +1481,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1478
1481
|
try {
|
|
1479
1482
|
const url = new URL(req.url, ORIGIN);
|
|
1480
1483
|
let p = url.pathname;
|
|
1481
|
-
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
1484
|
+
if (req.method === "GET" && p === "/login") return login(res, url, host);
|
|
1482
1485
|
// /v/<token>/deck/... and /v/<token>/files/...: the deck's pages and files, read-only, nothing else
|
|
1483
1486
|
let viewing = false;
|
|
1484
1487
|
if (p.startsWith("/v/")) {
|
|
@@ -1526,7 +1529,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1526
1529
|
const f = deckPath;
|
|
1527
1530
|
if (!viewing && /\.html$/i.test(f)) return toView(p);
|
|
1528
1531
|
const mode = url.searchParams.get("editor");
|
|
1529
|
-
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
|
|
1532
|
+
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing, presentHost(host)));
|
|
1530
1533
|
return serveFile(res, f, (s) => {
|
|
1531
1534
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1532
1535
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -1537,7 +1540,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1537
1540
|
}
|
|
1538
1541
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1539
1542
|
}, req, ["shot", "dump", "pdf"].includes(mode) && headlessKeys.delete(url.searchParams.get("headless"))
|
|
1540
|
-
? deckHeaders(".css", viewing) : deckHeaders(f, viewing)); // headless Chrome's page, once (see chromeStart)
|
|
1543
|
+
? deckHeaders(".css", viewing) : deckHeaders(f, viewing, presentHost(host))); // headless Chrome's page, once (see chromeStart)
|
|
1541
1544
|
}
|
|
1542
1545
|
if (viewing) return text(res, 404, "Not found");
|
|
1543
1546
|
|
|
@@ -1872,8 +1875,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1872
1875
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1873
1876
|
const n = findSlides(parse(before[DECK_FILE])).findIndex((s) => s.attrs["data-eid"]?.value === b.eid) + 1;
|
|
1874
1877
|
const text = String(b.text || "").trim();
|
|
1878
|
+
const said = b.html ? decode(text.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "")).trim() : text; // Claude reads the words, not the tags
|
|
1875
1879
|
const turnId = recordHandEdit(before, r.html, text ? `Edited the notes of slide ${n}` : `Removed the notes of slide ${n}`,
|
|
1876
|
-
text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${
|
|
1880
|
+
text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${said.slice(0, 300)}".`
|
|
1877
1881
|
: `The user removed the speaker notes of slide ${n} (${b.eid}).`); // manual: the slide itself does not change
|
|
1878
1882
|
return json(res, 200, { turnId });
|
|
1879
1883
|
}
|