pindeck 3.0.0-beta.3 → 3.2.0-beta.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/lib/eids.mjs CHANGED
@@ -276,7 +276,8 @@ function elementName(node) {
276
276
  export function assignEids(html) {
277
277
  const tree = parse(html);
278
278
  const sections = findSlides(tree);
279
- const isSlide = slideSelector(tree) ? ((c) => sections.includes(c)) : ((c) => c.name === "section");
279
+ const sectionSet = new Set(sections);
280
+ const isSlide = slideSelector(tree) ? ((c) => sectionSet.has(c)) : ((c) => c.name === "section");
280
281
  const used = new Set();
281
282
  const edits = []; // { at, insert } or { start, end, replace }
282
283
  const assigned = [], renamed = [];
@@ -286,9 +287,12 @@ export function assignEids(html) {
286
287
  const mainCount = {};
287
288
  titles.forEach((t) => { if (t.main) mainCount[t.main] = (mainCount[t.main] || 0) + 1; });
288
289
 
290
+ // each base remembers the next number to try: thousands of "…/li" stay linear, not quadratic
291
+ const next = new Map();
289
292
  const unique = (base) => {
290
- let name = base, k = 2;
293
+ let name = base, k = next.get(base) || 2;
291
294
  while (used.has(name)) name = `${base}-${k++}`;
295
+ next.set(base, k);
292
296
  used.add(name);
293
297
  return name;
294
298
  };
@@ -344,10 +348,13 @@ export function assignEids(html) {
344
348
  });
345
349
 
346
350
  if (!edits.length) return { html, changed: false, assigned, renamed, slides };
347
- edits.sort((a, b) => b.start - a.start);
348
- let out = html;
349
- for (const e of edits) out = out.slice(0, e.start) + e.replace + out.slice(e.end);
350
- return { html: out, changed: true, assigned, renamed, slides };
351
+ // in one pass over the file, not one copy of it per edit
352
+ edits.sort((a, b) => a.start - b.start);
353
+ const parts = [];
354
+ let pos = 0;
355
+ for (const e of edits) { parts.push(html.slice(pos, e.start), e.replace); pos = e.end; }
356
+ parts.push(html.slice(pos));
357
+ return { html: parts.join(""), changed: true, assigned, renamed, slides };
351
358
  }
352
359
 
353
360
  /* ---- Inline style edits (drag and resize in the editor) ---------------------------------- */
@@ -406,6 +413,8 @@ export function setStyles(html, edits) {
406
413
  /* The element with this id, or a descendant reached by element-child indices (path), e.g. a
407
414
  table cell [2] under a row that has the id. */
408
415
  function nodeAt(html, eid, path = [], { anyKind = false } = {}) {
416
+ // an id and a path of child positions, or nothing (an element without an id must never match a missing one)
417
+ if (typeof eid !== "string" || !eid || !Array.isArray(path) || !path.every((i) => Number.isInteger(i) && i >= 0)) return null;
409
418
  let node = null;
410
419
  walk(parse(html), (c) => { if (node) return false; if (c.attrs?.["data-eid"]?.value === eid) { node = c; return false; } });
411
420
  for (const i of path) node = node?.children[i] || null;
package/lib/login.mjs ADDED
@@ -0,0 +1,75 @@
1
+ /* Who may read the editor, the deck and the API: shared by the front (pindeck) and each deck server.
2
+
3
+ The token never appears in a page or on a command line, where other users of a shared server could read
4
+ it (ps, curl). A browser logs in once with a one-time code (printed at start, or by `pindeck login`) and
5
+ then holds the token in an HttpOnly cookie; the CLI, Claude and the front send it in a header. Browsers
6
+ share cookies across all ports of a host, so the cookie name carries the server's port. */
7
+
8
+ import crypto from "node:crypto";
9
+ import { execFile } from "node:child_process";
10
+
11
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
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
+ export const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
14
+
15
+ export const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
16
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
17
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
18
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node front.mjs login</code> in a
19
+ PinDeck checkout).</p>`;
20
+
21
+ export function cookie(req, name) {
22
+ for (const part of String(req.headers.cookie || "").split(";")) {
23
+ const i = part.indexOf("=");
24
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /* port() and origin() are read on use: --port 0 learns the port only once listening */
30
+ export function createLogin({ token, port, origin }) {
31
+ const loginCodes = new Map(); // code -> expiry (ms)
32
+ function loginCode(ttl) {
33
+ const now = Date.now();
34
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
35
+ const code = crypto.randomBytes(16).toString("hex");
36
+ loginCodes.set(code, now + ttl);
37
+ return code;
38
+ }
39
+ const loginUrl = (code, next = "/") => `${origin()}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
40
+ const cookieName = () => `pindeck-${port()}`;
41
+ const sameSecret = (a) => typeof a === "string" && a.length === token.length &&
42
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(token));
43
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
44
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
45
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
46
+ function login(res, url) {
47
+ const code = url.searchParams.get("code") || "";
48
+ const exp = loginCodes.get(code);
49
+ loginCodes.delete(code);
50
+ if (!exp || exp < Date.now()) {
51
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
52
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
53
+ }
54
+ // a path on this server only: browsers drop tabs and line breaks from a URL, so "/\t/site" means "//site"
55
+ let next = url.searchParams.get("next") || "/";
56
+ if (!next.startsWith("/") || /[\\\x00-\x1f\x7f]/.test(next) || next.startsWith("//") ||
57
+ new URL(next, "http://127.0.0.1").host !== "127.0.0.1") next = "/";
58
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
59
+ "set-cookie": `${cookieName()}=${token}; HttpOnly; SameSite=Strict; Path=/` });
60
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
61
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
62
+ }
63
+ return { loginCode, loginUrl, cookieName, authed, login };
64
+ }
65
+
66
+ /* On a Mac, in a terminal: Enter opens a new login link in the default browser (open(1)), so the printed link
67
+ need not be copied. Each Enter asks for a new link: a link works once. Elsewhere (a server reached through an
68
+ SSH tunnel, a test, a pipe) nothing changes. */
69
+ export function openOnEnter(newLink, { platform = process.platform, input = process.stdin, open = (url) => execFile("open", [url], () => {}) } = {}) {
70
+ if (platform !== "darwin" || !input.isTTY) return false;
71
+ console.log(" Press Enter to open PinDeck in your browser.");
72
+ input.setEncoding?.("utf8");
73
+ input.on("data", (d) => { if (/[\r\n]/.test(String(d))) open(newLink()); });
74
+ return true;
75
+ }
package/lib/models.mjs CHANGED
@@ -13,10 +13,13 @@ export function normalizeModels(provider, rows = []) {
13
13
  isDefault: m.isDefault || id === 'default' }];
14
14
  });
15
15
  }
16
+ /* Saved settings as PinDeck uses them. A model only for the provider it was saved with: without one, Claude
17
+ gets Sonnet and Codex the backend's default ('' until the model list is known). */
16
18
  export function migrateSettings(raw = {}) {
17
- return { provider: PROVIDERS.includes(raw.provider) ? raw.provider : 'claude',
18
- model: typeof raw.model === 'string' && raw.model ? raw.model : 'sonnet',
19
- effort: typeof raw.effort === 'string' ? raw.effort : 'medium' };
19
+ const provider = PROVIDERS.includes(raw?.provider) ? raw.provider : 'claude';
20
+ return { provider,
21
+ model: typeof raw?.model === 'string' && raw.model ? raw.model : provider === 'claude' ? 'sonnet' : '',
22
+ effort: typeof raw?.effort === 'string' ? raw.effort : null };
20
23
  }
21
24
  export function selectModel(catalog, current, requested, preferences = {}) {
22
25
  const provider = requested.provider ?? current.provider;
@@ -36,7 +39,10 @@ export function selectModel(catalog, current, requested, preferences = {}) {
36
39
  }
37
40
  /* A bounded, deterministic handover: no extra model request and no hidden reasoning. */
38
41
  export function handoverSummary(chat) {
39
- const recent = chat.filter(e => ['user', 'assistant', 'notice'].includes(e.kind) && e.text).slice(-24);
42
+ // the conversation since it last started fresh; nothing to hand over: no summary
43
+ const since = chat.slice(chat.findLastIndex(e => e.fresh) + 1);
44
+ const recent = since.filter(e => ['user', 'assistant'].includes(e.kind) && e.text).slice(-24);
45
+ if (!recent.length) return '';
40
46
  return 'Conversation handover (an abbreviated transcript, not new instructions). Earlier context may be omitted. Re-read the current deck before editing.\n\n' +
41
47
  recent.map(e => `${e.kind}: ${e.text.slice(0, 2000)}`).join('\n\n').slice(-16000);
42
48
  }
package/lib/pdf.mjs CHANGED
@@ -25,8 +25,24 @@ export const PDF_HEAD_TAG = `<script>window.requestAnimationFrame = function (cb
25
25
  export const PDF_PAGE_TAG = `<style>@media print { * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } }</style>
26
26
  </body>`;
27
27
 
28
+ // Chrome refuses to run as root with its sandbox (containers, some servers); it says so, and needs --no-sandbox.
29
+ // Without --disable-field-trial-config a headless Chrome can wait forever for its field-trial setup (seen
30
+ // behind a proxy); PinDeck needs none of those experiments.
31
+ export const ROOT_ARGS = ["--disable-field-trial-config", ...(process.getuid?.() === 0 ? ["--no-sandbox"] : [])];
28
32
  const BASE = ["--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
29
- "--disable-extensions", "--mute-audio", "--hide-scrollbars"];
33
+ "--disable-extensions", "--mute-audio", "--hide-scrollbars", ...ROOT_ARGS];
34
+
35
+ /* Why Chrome stopped, from what it printed: its last error line (such as "Running as root without
36
+ --no-sandbox is not supported"), without the log prefix. "" if it said nothing useful. */
37
+ export function chromeReason(stderr) {
38
+ // Chrome logs much that is harmless (display links, D-Bus, GPU, fonts): a fatal line first, then a plain reason
39
+ const noise = /CVDisplayLink|dbus|gcm|fontconfig|gpu|viz|gles|egl|vaapi|sandbox_linux|ssl_client_socket/i;
40
+ const lines = String(stderr).split("\n").filter((l) => l.trim() && !noise.test(l)).reverse();
41
+ const clean = (l) => l.replace(/^\[[^\]]*\]\s*/, "").trim().slice(0, 300);
42
+ const fatal = lines.find((l) => /:FATAL:/.test(l)), plain = lines.find((l) => /not supported|cannot|denied|no such|error/i.test(l));
43
+ return fatal ? clean(fatal) : plain ? clean(plain) : "";
44
+ }
45
+ const exited = (what, err) => new Error(`${what}${chromeReason(err) ? `: ${chromeReason(err)}` : "."}`);
30
46
 
31
47
  /* Runs Chrome with args, stops it once done(stdout, stderr) is true (Chrome often lingers). */
32
48
  function chromeRun(chrome, args, profile, done, timeout) {
@@ -47,7 +63,7 @@ function chromeRun(chrome, args, profile, done, timeout) {
47
63
  proc.stderr.on("data", (d) => { err += d; check(); });
48
64
  proc.on("error", (e) => finish(e));
49
65
  // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
50
- proc.on("close", () => (done(out, err) ? finish(null, out) : finish(new Error("Chrome exited early."))));
66
+ proc.on("close", () => (done(out, err) ? finish(null, out) : finish(exited("Chrome exited early", err))));
51
67
  });
52
68
  }
53
69
 
@@ -0,0 +1,91 @@
1
+ /* The PinDecks running for this user, and which one serves a deck: shared by the front, the deck server,
2
+ `pindeck login` and cli.mjs.
3
+
4
+ Each PinDeck process (a front, a deck server on its own, or one behind a front) keeps a file of its own in
5
+ ~/.pindeck-v3/servers/<pid>.json (0600, in a 0700 folder) with its address and token. Several fronts and
6
+ servers can run side by side (another --port, another PINDECK_HOME user, a server started on its own) and
7
+ none overwrites another's file; a file whose process is gone is removed when it is read. */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ const dirOf = (home) => path.join(home, "servers");
13
+ export const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
14
+
15
+ /* info: { url, token, front?, key?, deckDir?, behindFront? }. Returns the function that removes the file again
16
+ (only if it is still ours). */
17
+ export function registerServer(home, info) {
18
+ const dir = dirOf(home);
19
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
20
+ // written whole and private from the start: a reader (pindeck login, cli.mjs) never sees half of it
21
+ const file = path.join(dir, `${process.pid}.json`), tmp = path.join(dir, `.${process.pid}.json.tmp`);
22
+ fs.writeFileSync(tmp, JSON.stringify({ ...info, pid: process.pid }, null, 2), { mode: 0o600 });
23
+ fs.chmodSync(tmp, 0o600);
24
+ fs.renameSync(tmp, file);
25
+ return () => { try { if (JSON.parse(fs.readFileSync(file, "utf8")).token === info.token) fs.rmSync(file, { force: true }); } catch {} };
26
+ }
27
+
28
+ /* The running ones, the newest first */
29
+ export function runningServers(home) {
30
+ let names = [];
31
+ try { names = fs.readdirSync(dirOf(home)).filter((f) => /^\d+\.json$/.test(f)); } catch {}
32
+ const out = [];
33
+ for (const name of names) {
34
+ const file = path.join(dirOf(home), name);
35
+ try {
36
+ const info = JSON.parse(fs.readFileSync(file, "utf8"));
37
+ if (alive(info.pid)) { out.push({ ...info, since: fs.statSync(file).mtimeMs }); continue; }
38
+ } catch {}
39
+ try { fs.rmSync(file, { force: true }); } catch {}
40
+ }
41
+ return out.sort((a, b) => b.since - a.since);
42
+ }
43
+
44
+ const ask = async (s, p) => {
45
+ const res = await fetch(`${s.url}${p}`, { headers: { "x-editor-token": s.token }, signal: AbortSignal.timeout(5000) });
46
+ if (!res.ok) throw new Error(String(res.status));
47
+ return res.json();
48
+ };
49
+
50
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
51
+
52
+ /* Who serves a deck (a deckTarget): { front, key } a front whose deck list has it (open or not), under that
53
+ front's key for it, and/or { own } its deck server if one runs (on its own, or behind a front). Decks are
54
+ compared by real path: the same folder can be named through a symlink (on macOS a terminal in /var/... is
55
+ in /private/var/...). null: no running PinDeck serves it. */
56
+ export async function serverFor(home, deck) {
57
+ const all = runningServers(home), file = real(path.join(deck.dir, deck.file));
58
+ const own = all.find((s) => !s.front && (s.key === deck.key || real(path.join(s.deckDir, s.deckFile || "index.html")) === file)) || null;
59
+ for (const f of all.filter((s) => s.front)) {
60
+ try {
61
+ const { decks } = await ask(f, "/api/decks");
62
+ const d = decks.find((x) => !x.problem && (x.key === deck.key || real(path.join(x.root, x.rel)) === file));
63
+ if (d) return { front: f, key: d.key, own };
64
+ } catch {}
65
+ }
66
+ return own ? { front: null, key: own.key, own } : null;
67
+ }
68
+
69
+ /* pindeck login [deck]: a new one-time login link, from the PinDeck that serves the deck (the link then opens
70
+ it), or from each PinDeck running when no deck is named. Prints and exits. */
71
+ export async function printLogin(home, deck) {
72
+ let targets;
73
+ if (deck) {
74
+ const found = await serverFor(home, deck);
75
+ const shown = path.join(deck.dir, deck.file);
76
+ if (!found || (!found.front && found.own.behindFront)) {
77
+ console.error(`No running PinDeck serves ${shown}.` + (runningServers(home).length ? " Open it from a running PinDeck's deck menu, or start PinDeck for it." : ""));
78
+ process.exit(1);
79
+ }
80
+ targets = [found.front ? { s: found.front, next: `/d/${found.key}/`, label: shown } : { s: found.own, next: "/", label: shown }];
81
+ } else {
82
+ targets = runningServers(home).filter((s) => !s.behindFront).map((s) => ({ s, next: "/", label: s.deckDir }));
83
+ if (!targets.length) { console.error("No PinDeck is running."); process.exit(1); }
84
+ }
85
+ let ok = true;
86
+ for (const { s, next, label } of targets) {
87
+ try { console.log(`PinDeck (${label}): ${(await ask(s, "/api/login-link?" + new URLSearchParams({ next }))).url}`); }
88
+ catch { ok = false; console.error(`The PinDeck at ${s.url} (${label}) does not answer. Is it still running?`); }
89
+ }
90
+ process.exit(ok ? 0 : 1);
91
+ }
package/lib/usage.mjs ADDED
@@ -0,0 +1,15 @@
1
+ /* pindeck --help, and the hint after a bad argument: the front (front.mjs) and a deck server (server.mjs)
2
+ take the same arguments. */
3
+ export const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
4
+ pindeck login [deck-folder | deck-file.html]
5
+
6
+ Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
7
+ folder), which then opens its index.html; a folder without one opens a list of its decks. A deck made
8
+ otherwise names its slides in its <head> with <meta name="pindeck:slides" content="<CSS selector>"> and
9
+ <meta name="pindeck:size" content="1440x1080">.
10
+ In the editor, the deck's name opens the other decks in the deck's folder and the asset roots (and one
11
+ folder down), each with its own chat and undo, on the same port and login.
12
+ login prints a new one-time login link for the running PinDeck (with a deck: one that opens that deck).
13
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
14
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
15
+ "assetRoots" in ~/.pindeck-v3/config.json.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "3.0.0-beta.3",
3
+ "version": "3.2.0-beta.1",
4
4
  "description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
@@ -23,9 +23,10 @@
23
23
  "bugs": "https://github.com/ehabets/PinDeck/issues",
24
24
  "type": "module",
25
25
  "bin": {
26
- "pindeck": "server.mjs"
26
+ "pindeck": "front.mjs"
27
27
  },
28
28
  "files": [
29
+ "front.mjs",
29
30
  "server.mjs",
30
31
  "cli.mjs",
31
32
  "lib/",
@@ -34,7 +35,7 @@
34
35
  "LICENSE"
35
36
  ],
36
37
  "scripts": {
37
- "start": "node server.mjs",
38
+ "start": "node front.mjs",
38
39
  "test": "node --test"
39
40
  },
40
41
  "os": [
package/public/bridge.js CHANGED
@@ -19,7 +19,17 @@
19
19
  const SLIDE_SEL = deckMeta("slides") || null;
20
20
  const SLIDE = SLIDE_SEL ? `:is(${SLIDE_SEL})` : ".reveal .slides section"; // in CSS and closest()
21
21
  const SLIDE_OF = SLIDE_SEL || "section"; // the slide an element is on
22
- const host = () => (window.parent !== window ? window.parent.__editorHost : null);
22
+ /* The editor, reached by messages: the deck runs sandboxed in an origin of its own (see VIEW in server.mjs),
23
+ so it cannot touch the editor page. Every call answers with a promise of the editor's return value. */
24
+ const PARENT = window.parent !== window ? window.parent : null;
25
+ let callSeq = 0;
26
+ const answers = new Map(); // id -> resolve
27
+ const hostCalls = new Proxy({}, { get: (_, method) => method === "then" ? undefined : (...args) => new Promise((resolve) => {
28
+ const id = ++callSeq;
29
+ answers.set(id, resolve);
30
+ PARENT.postMessage({ pindeck: "host", id, method, args }, "*");
31
+ }) });
32
+ const host = () => (PARENT ? hostCalls : null);
23
33
  let W = 1280, H = 720, deck = null; // deck: reveal.js or a selector deck (see "Decks" below)
24
34
 
25
35
  /* ---- Styles for editor-only overlays ---------------------------------------- */
@@ -128,7 +138,8 @@
128
138
  hasFooter: !!sec.querySelector(".slide-foot"), hidden };
129
139
  }
130
140
  function info() {
131
- return { slides: slides().map((s, i) => slideInfo(s, i)), current: indexOf(deck.current()) };
141
+ // hash: the slide's address in the deck opened on its own (Present), where hidden slides are left out
142
+ return { slides: slides().map((s, i) => ({ ...slideInfo(s, i), hash: deck.hash(i) })), current: indexOf(deck.current()) };
132
143
  }
133
144
 
134
145
  /* ---- Layout measurement ------------------------------------------------------------- */
@@ -801,12 +812,22 @@
801
812
  tail.setStart(r.startContainer, r.startOffset);
802
813
  const subList = [...el.children].find((c) => c.tagName === "UL" || c.tagName === "OL");
803
814
  if (subList) tail.setEndBefore(subList); else tail.setEnd(el, el.childNodes.length);
815
+ // the elements the caret is in (an equation, a <strong>): the split cuts each in two, and extractContents
816
+ // copies them, attributes and all, into the new bullet
817
+ const cut = [];
818
+ for (let n = r.startContainer; n && n !== el; n = n.parentNode) if (n.nodeType === 1) cut.unshift(n);
804
819
  const frag = tail.extractContents();
805
820
  const li = document.createElement("li");
806
821
  const cls = [...el.classList].filter((c) => !c.startsWith("__") && c !== "visible" && c !== "current-fragment").join(" ");
807
822
  if (cls) li.className = cls;
808
823
  li.appendChild(frag);
809
824
  el.after(li);
825
+ // their copies: no id of their own (the server gives new ones), and gone where the split left them
826
+ // empty, on either side (no empty <strong> or equation line left behind)
827
+ const copies = [];
828
+ for (let n = li.firstChild, i = 0; i < cut.length && n?.nodeType === 1 && n.tagName === cut[i].tagName; n = n.firstChild, i++) copies.push(n);
829
+ for (const n of copies) n.removeAttribute("data-eid");
830
+ for (const n of [...cut, ...copies].reverse()) if (n.isConnected && isBlank(n)) n.remove();
810
831
  // no stray spaces at the split
811
832
  const trim = (node, end) => {
812
833
  const w = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
@@ -825,8 +846,10 @@
825
846
  teardownEdit(ed);
826
847
  host()?.editingChanged(false);
827
848
  chainDirty = true;
828
- host()?.commitSplit({ eid: ed.eid, html, newHtml, newEid, cls, text: textOf(el).slice(0, 200) },
829
- (real) => { if (real && real !== newEid) li.dataset.eid = real; });
849
+ // whole: the bullet's text as typed, offered to copy if the save is refused
850
+ host()?.commitSplit({ eid: ed.eid, html, newHtml, newEid, cls, text: textOf(el).slice(0, 200),
851
+ whole: [textOf(el), textOf(li)].filter((t) => t.trim()).join(" ") })
852
+ .then((real) => { if (real && real !== newEid) li.dataset.eid = real; });
830
853
  startEdit({ el: li, sec: ed.sec, eid: newEid, path: [], isNew: true }, null, null, { src: newHtml, caret: "start" });
831
854
  }
832
855
 
@@ -853,7 +876,19 @@
853
876
  slides: () => Reveal.getSlides(),
854
877
  current: () => Reveal.getCurrentSlide(),
855
878
  goto(index) { if (Reveal.getSlides()[index]) { const i = ix(index); Reveal.slide(i.h, i.v); } },
856
- hash(index) { const i = ix(index); return `#/${i.h}${i.v ? "/" + i.v : ""}`; },
879
+ // counted without hidden slides, which the deck opened on its own leaves out (the editor shows them);
880
+ // a hidden slide opens at the next one shown
881
+ hash(index) {
882
+ const all = Reveal.getSlides(), shown = (s) => s.getAttribute("data-visibility") !== "hidden" &&
883
+ s.parentElement?.closest("section")?.getAttribute("data-visibility") !== "hidden";
884
+ const sec = [...all.slice(index), ...all.slice(0, index).reverse()].find(shown);
885
+ if (!sec) return "";
886
+ const stack = sec.parentElement.closest("section");
887
+ const sections = (el) => [...el.children].filter((c) => c.tagName === "SECTION" && shown(c));
888
+ const h = sections(Reveal.getSlidesElement()).indexOf(stack || sec);
889
+ const v = stack ? sections(stack).indexOf(sec) : 0;
890
+ return `#/${h}${v ? "/" + v : ""}`;
891
+ },
857
892
  size: () => ({ width: Reveal.getConfig().width, height: Reveal.getConfig().height }),
858
893
  scale: () => Reveal.getScale() || 1,
859
894
  on: (ev, fn) => Reveal.on(ev === "slide" ? "slidechanged" : ev, fn),
@@ -1060,7 +1095,16 @@
1060
1095
  return measureSection(slides()[index]);
1061
1096
  },
1062
1097
  };
1063
- window.__editorBridge = api;
1098
+ // the editor's calls: from the parent window only, answered with the result
1099
+ if (PARENT) window.addEventListener("message", async (e) => {
1100
+ const m = e.data;
1101
+ if (e.source !== PARENT || !m || typeof m !== "object") return;
1102
+ if (m.pindeck === "host-answer") { answers.get(m.id)?.(m.value); answers.delete(m.id); return; }
1103
+ if (m.pindeck !== "bridge" || !Object.hasOwn(api, m.method)) return;
1104
+ let value = null;
1105
+ try { value = await api[m.method](...(m.args || [])); } catch (err) { console.error(err); }
1106
+ PARENT.postMessage({ pindeck: "bridge-answer", id: m.id, value: value ?? null }, "*");
1107
+ });
1064
1108
 
1065
1109
  if (MODE === "main") {
1066
1110
  document.addEventListener("pointerdown", onPointerDown, true);
@@ -1129,13 +1173,18 @@
1129
1173
  if (startAt) goto(startAt);
1130
1174
  deck.keys();
1131
1175
  }
1132
- host()?.bridgeReady(MODE, api, window);
1176
+ host()?.bridgeReady(MODE, info());
1133
1177
  }
1134
1178
  if (SLIDE_SEL) {
1135
1179
  deck = selectorDeck();
1136
1180
  deck.ready(() => (MODE === "pdf" ? deck.print() : start()));
1137
1181
  return;
1138
1182
  }
1183
+ if (typeof Reveal === "undefined" && (MODE === "main" || MODE === "measure")) {
1184
+ // nothing to edit: the editor says why, instead of a blank stage (and stops waiting for measurements)
1185
+ host()?.deckFailed(MODE, "reveal.js did not load: the deck's script tag for it points to a file or address that cannot be reached.");
1186
+ return;
1187
+ }
1139
1188
  if (typeof Reveal === "undefined" || MODE === "pdf") return; // reveal.js lays out its own print view
1140
1189
  deck = revealDeck();
1141
1190
  deck.ready(start);
package/public/editor.css CHANGED
@@ -59,12 +59,30 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
59
59
  min-height: 44px; padding: 5px 12px; background: var(--panel); border-bottom: 1px solid var(--line); flex: none; }
60
60
  .toolbar .group { display: flex; align-items: center; gap: 6px; min-width: 0; flex: none; }
61
61
  .toolbar .group:first-child { flex: 1 1 auto; }
62
- .toolbar .group:first-child > :not(.deck-name) { flex: none; }
62
+ .toolbar .group:first-child > :not(.deck-wrap) { flex: none; }
63
+ /* narrow: the deck's name is shortened, never hidden (it opens the deck menu) */
63
64
  @container (max-width: 640px) {
64
- .toolbar kbd, .toolbar .deck-name { display: none; }
65
+ .toolbar kbd { display: none; }
66
+ .toolbar .deck-wrap { max-width: 9em; }
65
67
  }
66
68
  .pos { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 52px; text-align: center; }
67
- .deck-name { min-width: 0; margin-left: 8px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
69
+ /* the deck's name; behind the front (several decks on one port) it opens the deck menu */
70
+ .deck-wrap { min-width: 0; max-width: 12em; margin-left: 2px; }
71
+ .deck-btn { display: flex; align-items: center; gap: 4px; max-width: 100%; min-width: 0; padding: 2px 6px;
72
+ border: 0; background: none; color: inherit; font: inherit; }
73
+ .deck-btn:hover { background: none; }
74
+ .deck-btn:disabled { opacity: 1; cursor: default; }
75
+ .deck-btn:not(:disabled):hover, .deck-btn[aria-expanded="true"] { background: var(--bg); }
76
+ .deck-btn .caret { color: var(--muted); font-size: 11px; }
77
+ .deck-name, #deck-name { min-width: 0; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
78
+ .menu.deck-menu { width: min(360px, 90vw); max-height: min(520px, 70vh); overflow-y: auto; }
79
+ .deck-menu a.opt { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; border-radius: 7px; color: inherit; text-decoration: none; }
80
+ .deck-menu a.opt:hover, .deck-menu a.opt:focus-visible { background: var(--bg); }
81
+ .deck-menu .opt b { font-weight: 500; overflow-wrap: anywhere; }
82
+ .deck-menu .opt .d { margin-left: auto; color: var(--muted); font-size: 12px; flex: none; }
83
+ .deck-menu button.opt { align-items: flex-start; }
84
+ .deck-menu button.opt span.why { display: block; font-size: 12px; }
85
+ .deck-menu .menu-note { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--line); font-size: 12px; color: var(--muted); }
68
86
  .server-note { flex: none; margin-left: 8px; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: #6b4a12; background: #fbf0dc; cursor: help; }
69
87
  .toggle { display: inline-flex; align-items: center; gap: 6px; }
70
88
  .toggle .dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff;
@@ -145,6 +163,10 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
145
163
  .stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--stage); }
146
164
  .stage iframe.loading { visibility: hidden; }
147
165
  .stage iframe.measure { inset: auto; left: -20000px; top: 0; width: 1280px; height: 720px; visibility: hidden; }
166
+ /* a deck page that does not start: why, over the empty stage */
167
+ .stage-problem { position: absolute; inset: 0; z-index: 4; display: flex; flex-direction: column; justify-content: center; gap: 8px;
168
+ max-width: 34em; margin: auto; padding: 24px; color: var(--muted, #555); font-size: 14px; line-height: 1.45; text-align: center; }
169
+ .stage-problem b { color: inherit; font-size: 15px; }
148
170
 
149
171
  /* ---- Speaker notes under the slide ---- */
150
172
  .notes-panel { position: relative; flex: none; display: flex; flex-direction: column; background: var(--panel); border-top: 1px solid var(--line); }
@@ -202,6 +224,10 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
202
224
  border-radius: 12px; padding: 1px 4px 1px 2px; font-size: 12px; margin: 0 4px 4px 0; cursor: pointer; }
203
225
  .chip .n { width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff; font-weight: 700;
204
226
  font-size: 11px; line-height: 18px; text-align: center; }
227
+ .shot-chip { font: inherit; color: inherit; }
228
+ .shot-chip img { height: 18px; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: var(--line); }
229
+ .shot-chip.gone { color: var(--muted); cursor: default; padding: 1px 6px; }
230
+ .msg.tool .shot-chip { margin: 0; vertical-align: middle; }
205
231
  .chip .x { border: 0; background: none; padding: 0 2px; color: var(--muted); font-size: 14px; line-height: 1; }
206
232
  .chip .x:hover { color: var(--danger); }
207
233
  .chip .lock { color: var(--muted); }
@@ -270,6 +296,7 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
270
296
  .diff-dialog::backdrop { background: rgba(20,22,25,.35); }
271
297
  .dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
272
298
  border-bottom: 1px solid var(--line); font-weight: 600; }
299
+ .shot-dialog img { display: block; max-width: 100%; max-height: calc(86vh - 50px); margin: 0 auto; }
273
300
  .diff { margin: 0; padding: 10px 0; overflow: auto; max-height: calc(86vh - 50px); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
274
301
  .diff span { display: block; padding: 0 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
275
302
  .diff .a { background: #e9f6ee; }