pindeck 3.4.0 → 4.1.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/review.mjs ADDED
@@ -0,0 +1,200 @@
1
+ /* Review comments (after Sebastian J. Schlecht's review mode for PinDeck).
2
+
3
+ A comment is a note on a slide or one of its elements, kept for later instead of sent to the assistant now.
4
+ Comments live in <deck folder>/.pindeck/<deck>.review.json: a dot folder, so they are never deck files, never
5
+ part of an undo and never edited by the assistant. Each comment keeps what it needs to be found again after
6
+ the deck changes: the slide's id attribute (Quarto's heading anchor), title and position, and the element's
7
+ id, tag and text. A comment whose slide or element cannot be found again is flagged, never silently moved.
8
+ Next to the comments, <deck>.review.md is the review packet: the open comments, for an agent outside PinDeck. */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import crypto from "node:crypto";
13
+ import { assignEids, parse, findSlides, walk, textOf } from "./eids.mjs";
14
+ import { writeAtomic } from "./deck-files.mjs";
15
+
16
+ const norm = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
17
+ const classes = (n) => (n.attrs?.class?.value || "").split(/\s+/).filter(Boolean);
18
+ const tagOf = (n) => [n.name, ...classes(n)].join(".");
19
+
20
+ /* The deck as comments see it: per slide, its id attribute, title, position in the stacks and its elements by
21
+ id. Ids are assigned in memory (a deck PinDeck has opened has them already; a rendered Quarto deck gets them
22
+ again after each render). */
23
+ export function indexDeck(html) {
24
+ const withIds = assignEids(html).html;
25
+ const tree = parse(withIds);
26
+ const slides = findSlides(tree).map((sec, index) => {
27
+ const stacked = sec.parent?.name === "section";
28
+ const top = stacked ? sec.parent : sec;
29
+ const siblings = (n) => (n.parent?.children || []).filter((c) => c.name === sec.name);
30
+ let head = null;
31
+ walk(sec, (c) => { if (head) return false; if (/^h[1-3]$/.test(c.name)) { head = c; return false; } });
32
+ const elements = new Map();
33
+ walk(sec, (c) => {
34
+ if (c.name === "section" || (c.name === "aside" && classes(c).includes("notes"))) return false;
35
+ const eid = c.attrs?.["data-eid"]?.value;
36
+ if (!eid) return;
37
+ let fragment = false;
38
+ for (let p = c; p && p !== sec; p = p.parent) if (classes(p).includes("fragment")) fragment = true;
39
+ const src = c.name === "img" ? (c.attrs["data-src"] || c.attrs.src)?.value : null;
40
+ elements.set(eid, { eid, tag: tagOf(c), text: norm(src || textOf(withIds, c)), fragment });
41
+ });
42
+ return {
43
+ index, eid: sec.attrs["data-eid"]?.value, id: sec.attrs.id?.value || null,
44
+ title: head ? norm(textOf(withIds, head)) : "",
45
+ h: siblings(top).indexOf(top), v: stacked ? siblings(sec).indexOf(sec) : 0, elements,
46
+ };
47
+ });
48
+ return { html: withIds, slides };
49
+ }
50
+
51
+ /* A new comment from a pin placed in the editor (bridge.js onClick), or from the slide as a whole (pin null,
52
+ slide { index, eid }). */
53
+ export function commentFromPin(deck, { pin, slide: whole }, text) {
54
+ const ref = pin ? pin.slide : whole;
55
+ const slide = deck.slides[ref?.index];
56
+ if (!slide || slide.eid !== ref.eid) throw new Error("The slide has changed since the pin was placed. Pin it again.");
57
+ const onSlide = !pin || pin.eid === slide.eid;
58
+ const el = onSlide ? null : slide.elements.get(pin.eid);
59
+ if (!onSlide && !el) throw new Error("Element not found in the deck. Pin it again.");
60
+ const r = (v) => Math.round(v * 1e4) / 1e4;
61
+ const now = new Date().toISOString();
62
+ return {
63
+ id: "c-" + crypto.randomBytes(4).toString("hex"), created: now, updated: now, status: "open", text: String(text).trim(),
64
+ slide: { id: slide.id, eid: slide.eid, title: slide.title, h: slide.h, v: slide.v },
65
+ target: el ? { eid: el.eid, tag: el.tag, text: el.text, fragment: el.fragment, word: pin.word || null, cell: pin.cell || null }
66
+ : { eid: null, tag: "section", text: "", fragment: false, word: null, cell: null },
67
+ // where the marker sits: on the slide (px), and inside its element (0..1 of its box)
68
+ point: pin?.point ? { x: r(pin.point.x), y: r(pin.point.y) } : null,
69
+ at: pin?.at ? { fx: r(pin.at.fx), fy: r(pin.at.fy) } : null,
70
+ };
71
+ }
72
+
73
+ /* Where a stored comment is in the deck as it is now.
74
+ status: ok same slide, same element, same text
75
+ moved the element's text was found elsewhere on the slide (ids can be positional)
76
+ changed the element is there but its text differs: possibly already addressed
77
+ missing the slide is there, the element is not
78
+ no-slide neither the slide's id nor its title exists any more
79
+ Only "ok" and "moved" place the marker on the element; the others at the stored point on the slide (if any). */
80
+ export function locate(deck, c) {
81
+ // shortcut: repeated titles resolve to the first; add an h/v tiebreak if decks repeat titles without ids
82
+ const slide = (c.slide.id && deck.slides.find((s) => s.id === c.slide.id)) ||
83
+ (c.slide.eid && deck.slides.find((s) => s.eid === c.slide.eid && (!c.slide.title || s.title === c.slide.title))) ||
84
+ (c.slide.title && deck.slides.find((s) => s.title === c.slide.title));
85
+ if (!slide) return { status: "no-slide", slide: null, eid: null };
86
+ const t = c.target;
87
+ if (!t.eid) return { status: "ok", slide, eid: null };
88
+ const els = [...slide.elements.values()];
89
+ const same = slide.elements.get(t.eid);
90
+ if (same && same.tag === t.tag && same.text === t.text) return { status: "ok", slide, eid: same.eid };
91
+ const moved = t.text && (els.find((e) => e.tag === t.tag && e.text === t.text) || els.find((e) => e.text === t.text));
92
+ if (moved) return { status: "moved", slide, eid: moved.eid };
93
+ if (same && same.tag === t.tag) return { status: "changed", slide, eid: null, now: same.text };
94
+ return { status: "missing", slide, eid: null };
95
+ }
96
+ export const matched = (status) => status === "ok" || status === "moved";
97
+
98
+ /* ---- The comments file ------------------------------------------------------------------------ */
99
+ export function reviewFiles(deckDir, deckFile) {
100
+ const base = path.join(deckDir, ".pindeck", deckFile.replace(/\.html?$/i, ""));
101
+ return { json: base + ".review.json", packet: base + ".review.md" };
102
+ }
103
+ export function loadReview(file) {
104
+ try {
105
+ const r = JSON.parse(fs.readFileSync(file, "utf8"));
106
+ if (!r || !Array.isArray(r.comments)) throw new Error(`${file} is not a PinDeck comments file.`);
107
+ // edited by hand or by another agent: a comment without what locate() needs would crash the server later
108
+ const bad = r.comments.findIndex((c) => typeof c?.id !== "string" || typeof c.text !== "string" || !c.slide || typeof c.slide !== "object" || !c.target || typeof c.target !== "object");
109
+ if (bad >= 0) throw new Error(`comment ${bad + 1} lacks its id, text, slide or target`);
110
+ const badReplies = r.comments.findIndex((c) => c.replies != null && !(Array.isArray(c.replies) && c.replies.every((x) => typeof x?.text === "string")));
111
+ if (badReplies >= 0) throw new Error(`comment ${badReplies + 1} has replies that are not a list of { by, text }`);
112
+ return r;
113
+ } catch (e) { if (e.code === "ENOENT") return { pindeckReview: 1, deck: {}, comments: [] }; throw e; }
114
+ }
115
+ export function saveReview(file, review) {
116
+ fs.mkdirSync(path.dirname(file), { recursive: true });
117
+ writeAtomic(file, JSON.stringify(review, null, 2) + "\n"); // a crash never leaves half a file
118
+ }
119
+
120
+ /* ---- The review packet ------------------------------------------------------------------------------ */
121
+ const pandocId = (s) => norm(s.replace(/\{[^}]*\}\s*$/, "")).toLowerCase()
122
+ .replace(/[^\p{L}\p{N}\s_.-]/gu, "").replace(/^[^\p{L}]+/u, "").replace(/\s+/g, "-");
123
+
124
+ /* 1-based line of the slide's heading in a Quarto/Markdown source, or null. */
125
+ export function sourceLine(srcLines, slide) {
126
+ if (!srcLines || !slide) return null;
127
+ const i = srcLines.findIndex((l) => {
128
+ const m = /^#{1,3}\s+(.*)$/.exec(l);
129
+ if (!m) return false;
130
+ const explicit = /\{[^}]*#([\w:.-]+)/.exec(m[1]);
131
+ return explicit ? explicit[1] === slide.id : pandocId(m[1]) === slide.id || norm(m[1]) === slide.title;
132
+ });
133
+ return i < 0 ? null : i + 1;
134
+ }
135
+
136
+ const STATUS = {
137
+ ok: "ok", moved: "ok (element found again by its text)",
138
+ changed: "UNMATCHED: the element's text has changed since the comment (maybe already addressed)",
139
+ missing: "UNMATCHED: the element is no longer on this slide",
140
+ "no-slide": "UNMATCHED: the slide is no longer in the deck",
141
+ };
142
+ const clip = (s, n = 240) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
143
+ const slideName = (s) => `slide ${s.h + 1}${s.v ? "." + s.v : ""} “${s.title || "(untitled)"}”`;
144
+
145
+ /* The open comments in deck order, each with where it is now and its number in the editor's list (C1, C2, …) */
146
+ export function openComments(review, deck) {
147
+ const n = new Map(review.comments.map((c, i) => [c.id, i + 1]));
148
+ const key = (x) => (x.loc.slide ? x.loc.slide.index : 1e6) * 1e5 + (x.c.point?.y ?? 0);
149
+ return review.comments.filter((c) => c.status !== "resolved").map((c) => ({ c, n: n.get(c.id), loc: locate(deck, c) }))
150
+ .sort((a, b) => key(a) - key(b));
151
+ }
152
+ const who = (by) => (by === "you" ? "Reviewer" : by === "assistant" ? "Assistant" : by ? `${by[0].toUpperCase()}${by.slice(1)}` : "Agent");
153
+ function items(list, lines) {
154
+ return list.flatMap(({ c, n, loc }) => {
155
+ const s = loc.slide, t = c.target;
156
+ const line = sourceLine(lines, s || { id: c.slide.id, title: c.slide.title });
157
+ return [`## C${n}. ${s ? slideName(s) : "was " + slideName(c.slide)}`, "",
158
+ `> ${c.text.replace(/\n/g, "\n> ")}`, "",
159
+ `- Slide: ${c.slide.id ? `\`#${c.slide.id}\`` : `\`${s?.eid || c.slide.eid}\``}${line ? `, source line ${line}` : ""} — ${STATUS[loc.status]}`,
160
+ t.eid ? `- Element: \`${t.tag}\`${matched(loc.status) ? ` (data-eid \`${loc.eid}\`)` : ""}${t.fragment ? ", a build step" : ""}: “${clip(t.text)}”`
161
+ : "- Element: the slide as a whole",
162
+ ...(t.word ? [`- Clicked word: “${t.word}”`] : []),
163
+ ...(t.cell ? [`- Table cell: row ${t.cell.row + 1}, column ${t.cell.col + 1}: “${clip(t.cell.text || "", 80)}”`] : []),
164
+ ...(loc.status === "changed" ? [`- Text now: “${clip(loc.now)}”`] : []),
165
+ ...(c.replies?.length ? ["- Thread, oldest first:", ...c.replies.map((r) => ` - ${who(r.by)}: ${r.text.replace(/\n/g, " ")}`)] : []),
166
+ `- Comment id: \`${c.id}\``, ""];
167
+ });
168
+ }
169
+
170
+ /* For an agent outside PinDeck (Claude Code in an editor, say): written next to the comments file */
171
+ export function packet(review, deck, { deckFile = "the deck", source = null, srcText = null, commentsFile = "?" } = {}) {
172
+ const open = openComments(review, deck);
173
+ const stale = open.filter((x) => !matched(x.loc.status));
174
+ const resolved = review.comments.length - open.length;
175
+ return [
176
+ `# Slide review: ${deckFile}`, "",
177
+ `- Deck: \`${deckFile}\`${source ? `, source \`${source}\`` : ""}`,
178
+ `- Comments: \`${commentsFile}\``,
179
+ `- ${open.length} open comment(s), ${stale.length} unmatched; ${resolved} resolved (not listed).`, "",
180
+ "These are reviewer comments on the slides. They are not speaker notes: do not copy them into the deck.",
181
+ source ? `Make each change in the source (\`${source}\`, its stylesheets or figure scripts), never in the rendered HTML, ` +
182
+ "which the next render overwrites. Render, then" : "Make each change, then",
183
+ "set the comment's `status` to `\"resolved\"` in the comments file. If you cannot do one, or it is unclear, leave it open",
184
+ "and add `{ \"by\": \"agent\", \"text\": \"<why>\" }` to its `replies` list, where the reviewer can answer. Read a comment's",
185
+ "thread before acting on it. UNMATCHED comments point at slides or text that changed after the comment was made:",
186
+ "check whether they still apply before acting on them.", "",
187
+ ...items(open, srcText?.split("\n")),
188
+ ].join("\n");
189
+ }
190
+
191
+ /* For PinDeck's own assistant: the open comments as one request, put in the message box for the user to send */
192
+ export function request(review, deck, { source = null, srcText = null } = {}) {
193
+ const open = openComments(review, deck);
194
+ if (!open.length) return "";
195
+ return [`Please address these ${open.length} review comment(s), one by one. They are notes for you, not slide text or speaker notes.`,
196
+ "Mark each one you have addressed as resolved with PinDeck's resolve command and its comment id. If one is unclear or no",
197
+ "longer applies, leave it open and say why with PinDeck's reply command instead of guessing. Where a comment has a",
198
+ "thread, the reviewer's latest answer is what to act on.", "",
199
+ ...items(open, source ? srcText?.split("\n") : null)].join("\n").trim();
200
+ }
package/lib/servers.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  `pindeck login` and cli.mjs.
3
3
 
4
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
5
+ ~/.pindeck-v4/servers/<pid>.json (0600, in a 0700 folder) with its address and token. Several fronts and
6
6
  servers can run side by side (another --port, another PINDECK_HOME user, a server started on its own) and
7
7
  none overwrites another's file; a file whose process is gone is removed when it is read. */
8
8
 
@@ -69,7 +69,8 @@ this path to cli.mjs even if another one appears earlier in the conversation, ea
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.
72
- - To look at files, use Read, Grep and Glob.
72
+ - To look at files, use Read, Grep and Glob. A slide can be one very long line: to see one element of
73
+ it, run `{{CLI}} element <data-eid>...`, which prints that element's HTML exactly as written.
73
74
  - To check several slides, name them all: `{{CLI}} layout 9 10 11 --issues`.
74
75
 
75
76
  ## Checking your work
@@ -81,6 +82,15 @@ overlapping elements, clipped text); add --issues to print only the issues. Fix
81
82
  {{CLI}} shot <slide-eid or number>...
82
83
  only when you need a visual judgement the numbers cannot give (balance, figure content, colour)
83
84
  or the user asks; it prints the path of a PNG, which you then Read.
85
+ ## Review comments
86
+ The user can keep comments on slides (ids c-…, numbered C1, C2, … in the editor) and ask you to address them.
87
+ They are notes for you, not slide text or speaker notes. When you have addressed one, mark it resolved:
88
+ {{CLI}} resolve <comment id> <one line on what you did>
89
+ Leave a comment open when you could not do it, or it was unclear, and say why on the comment itself, where the
90
+ user can answer you:
91
+ {{CLI}} reply <comment id> <why not, or what you need to know>
92
+ A comment's thread (your replies and the user's) comes with it; read the user's latest answer before acting.
93
+
84
94
  If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build steps complete) or
85
95
  `{{CLI}} pdf presentation` (one page per build step); it prints the path of the PDF.
86
96
 
@@ -91,7 +101,8 @@ inline styles on the element in {{DECK}}: `translate: Xpx Ypx` for moves, `width
91
101
  keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
92
102
  The user can also edit text in place (double-click); you get a note with the new text. Keep their
93
103
  wording unless asked to change it.
94
- A selected element is listed in <editor-context>; "this" or "it" usually means that element.
104
+ A selected element is listed in <editor-context>; "this" or "it" usually means that element. When
105
+ several are selected, "these" or "them" means all of them.
95
106
 
96
107
  ## Replies
97
108
  Replies appear in a narrow sidebar. Keep them short: 1 to 4 sentences saying what you changed,
package/lib/usage.mjs CHANGED
@@ -1,15 +1,16 @@
1
1
  /* pindeck --help, and the hint after a bad argument: the front (front.mjs) and a deck server (server.mjs)
2
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>]...
3
+ export const USAGE = `Usage: pindeck [deck-folder | deck-file.html | quarto-deck.qmd] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
4
4
  pindeck login [deck-folder | deck-file.html]
5
5
 
6
6
  Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
7
7
  folder), which then opens its index.html; a folder without one opens a list of its decks. A deck made
8
8
  otherwise names its slides in its <head> with <meta name="pindeck:slides" content="<CSS selector>"> and
9
- <meta name="pindeck:size" content="1440x1080">.
9
+ <meta name="pindeck:size" content="1440x1080">. A Quarto deck (talk.qmd) is rendered by PinDeck, and the
10
+ assistant edits its source.
10
11
  In the editor, the deck's name opens the other decks in the deck's folder and the asset roots (and one
11
12
  folder down), each with its own chat and undo, on the same port and login.
12
13
  login prints a new one-time login link for the running PinDeck (with a deck: one that opens that deck).
13
14
  --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
14
15
  ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
15
- "assetRoots" in ~/.pindeck-v3/config.json.`;
16
+ "assetRoots" in ~/.pindeck-v4/config.json.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "3.4.0",
3
+ "version": "4.1.0-beta.1",
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",
@@ -32,6 +32,9 @@
32
32
  "lib/",
33
33
  "public/",
34
34
  "README.md",
35
+ "CHANGELOG.md",
36
+ "docs/*.md",
37
+ "!docs/v4-plan.md",
35
38
  "LICENSE"
36
39
  ],
37
40
  "scripts": {
package/public/bridge.js CHANGED
@@ -42,6 +42,9 @@
42
42
  background:#ED8C01; color:#fff; font:700 calc(14px*var(--pindeck-u,1))/calc(26px*var(--pindeck-u,1)) system-ui, sans-serif; text-align:center;
43
43
  box-shadow:0 0 0 calc(2px*var(--pindeck-u,1)) #fff, 0 calc(2px*var(--pindeck-u,1)) calc(6px*var(--pindeck-u,1)) rgba(0,0,0,.45);
44
44
  cursor:pointer; pointer-events:auto; }
45
+ .__pin.__comment { background:#1a73e8; border-radius:calc(6px*var(--pindeck-u,1)); width:auto; min-width:calc(26px*var(--pindeck-u,1));
46
+ padding:0 calc(5px*var(--pindeck-u,1)); }
47
+ .__pin.__stale { background:#8a8f98; }
45
48
  .__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--pindeck-u,1)) dashed #ED8C01;
46
49
  outline-offset:calc(2px*var(--pindeck-u,1)); background:rgba(237,140,1,.06); }
47
50
  .__hover span { position:absolute; left:calc(-2px*var(--pindeck-u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
@@ -64,7 +67,8 @@
64
67
  .__sel i[data-h="ne"] { left:100%; top:0; cursor:nesw-resize; } .__sel i[data-h="e"] { left:100%; top:50%; cursor:ew-resize; }
65
68
  .__sel i[data-h="se"] { left:100%; top:100%; cursor:nwse-resize; } .__sel i[data-h="s"] { left:50%; top:100%; cursor:ns-resize; }
66
69
  .__sel i[data-h="sw"] { left:0; top:100%; cursor:nesw-resize; } .__sel i[data-h="w"] { left:0; top:50%; cursor:ew-resize; }
67
- .__sel.readonly i { display:none; }
70
+ .__sel.readonly i, .__sel.multi i { display:none; }
71
+ html.__selmode ${SLIDE} .__selected.__member { outline:calc(1px*var(--pindeck-u,1)) dashed #1a73e8; outline-offset:0; }
68
72
  .__sel-label { position:absolute; left:calc(-1.5px*var(--pindeck-u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
69
73
  font:600 calc(11px*var(--pindeck-u,1))/calc(17px*var(--pindeck-u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--pindeck-u,1));
70
74
  border-radius:calc(3px*var(--pindeck-u,1)); font-variant-numeric:tabular-nums; }
@@ -102,19 +106,22 @@
102
106
  const byEid = (eid, root = document) => root.querySelector(`[data-eid="${CSS.escape(eid)}"]`);
103
107
 
104
108
  /* Visible text of an element, without KaTeX's hidden MathML copy. */
109
+ const MATH_TEX = 'script[type^="math/tex"]'; // MathJax 2 (Quarto) keeps the TeX here; its rendering is skipped
105
110
  function textOf(el, ownOnly) {
106
111
  if (el.tagName === "IMG") return el.getAttribute("alt") || (el.getAttribute("src") || "").split("/").pop();
107
112
  let out = "";
108
113
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
109
114
  acceptNode(n) {
110
115
  const p = n.parentElement;
111
- if (!p || p.closest(".katex-mathml, .__pin, .__hover, .__flash, .__sel, .__guide, .__edit-hint, .__hidden-banner, aside.notes, script, style")) return NodeFilter.FILTER_REJECT;
116
+ if (p?.matches(MATH_TEX)) return NodeFilter.FILTER_ACCEPT;
117
+ if (!p || p.closest(".katex-mathml, .MathJax, .MathJax_Display, .MathJax_Preview, .MathJax_CHTML, .MathJax_SVG, mjx-container, .__pin, .__hover, .__flash, .__sel, .__guide, .__edit-hint, .__hidden-banner, aside.notes, script, style")) return NodeFilter.FILTER_REJECT;
112
118
  if (ownOnly) { const owner = p.closest("[data-eid]"); if (owner !== el) return NodeFilter.FILTER_REJECT; }
113
119
  return NodeFilter.FILTER_ACCEPT;
114
120
  },
115
121
  });
116
122
  while (walker.nextNode()) {
117
123
  const n = walker.currentNode;
124
+ if (n.parentElement.matches(MATH_TEX)) { out += `\\(${n.nodeValue.trim()}\\) `; continue; }
118
125
  out += n.nodeValue + (n.parentElement.closest(".katex") ? "" : " "); // KaTeX splits math into one span per glyph
119
126
  }
120
127
  return out.replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
@@ -239,7 +246,8 @@
239
246
  const presentHash = (index) => deck.hash(index); // the slide's address in the deck opened on its own
240
247
 
241
248
  /* ---- Pins and hover ---------------------------------------------------------------------- */
242
- let mode = "select", pins = [], editable = true; // mode: "select" (drag, resize) or "pin"
249
+ // mode: "select" (drag, resize) or "pin"; lockReason: why hand edits are off for good (a Quarto deck)
250
+ let mode = "select", pins = [], editable = true, lockReason = null;
243
251
  const tagOf = (el) => el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !c.startsWith("__")).join(".") : "");
244
252
  function wordAt(x, y, el) {
245
253
  let node, offset;
@@ -250,7 +258,7 @@
250
258
  const r = document.caretRangeFromPoint(x, y);
251
259
  if (r) { node = r.startContainer; offset = r.startOffset; }
252
260
  }
253
- if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml")) return null;
261
+ if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml, .MathJax, mjx-container")) return null;
254
262
  const s = node.nodeValue;
255
263
  const isW = (c) => /[\p{L}\p{N}_\-.,%×]/u.test(c);
256
264
  let a = offset, b = offset;
@@ -276,6 +284,8 @@
276
284
  function onClick(e) {
277
285
  if (MODE !== "main") return;
278
286
  e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
287
+ const comment = e.target.closest?.(".__comment"); // a review comment's marker opens it, in either mode
288
+ if (comment) { host()?.openComment(comment.dataset.comment); return; }
279
289
  if (mode !== "pin") return;
280
290
  const marker = e.target.closest?.(".__pin");
281
291
  if (marker) { host()?.removePin(+marker.dataset.n); return; }
@@ -329,10 +339,11 @@
329
339
  x = b.x + p.at.fx * b.w; y = b.y + p.at.fy * b.h;
330
340
  }
331
341
  const m = document.createElement("div");
332
- m.className = "__pin";
342
+ m.className = "__pin" + (p.comment ? " __comment" : "") + (p.stale ? " __stale" : "");
333
343
  m.dataset.n = p.n;
344
+ if (p.comment) m.dataset.comment = p.comment;
334
345
  m.textContent = p.n;
335
- m.title = "Click to remove pin " + p.n;
346
+ m.title = p.title || "Click to remove pin " + p.n;
336
347
  Object.assign(m.style, { left: x + "px", top: y + "px" });
337
348
  sec.appendChild(m);
338
349
  }
@@ -379,8 +390,22 @@
379
390
  const fmtTranslate = (x, y) => (r1(x) || r1(y) ? `${r1(x)}px ${r1(y)}px` : "");
380
391
  const nameOf = (el, sec) => { const e = el.dataset.eid || ""; return e.startsWith(sec.dataset.eid + "/") ? e.slice(sec.dataset.eid.length) : e; };
381
392
 
393
+ /* The selection: one element, or several on the same slide (⌘-click adds and removes). `el` is the one
394
+ clicked last: it is what Enter edits and what the assistant gets first. Several move together and have no
395
+ resize handles. */
396
+ const many = () => sel.els.length > 1;
397
+ const groupName = () => many() ? `${sel.els.length} elements (${sel.els.map((e) => nameOf(e, sel.sec)).join(", ")})` : nameOf(sel.el, sel.sec);
398
+ function unionBox(els, sec) {
399
+ const bs = els.map((e) => boxOf(e, sec));
400
+ const x = Math.min(...bs.map((b) => b.x)), y = Math.min(...bs.map((b) => b.y));
401
+ return { x, y, w: Math.max(...bs.map((b) => b.x + b.w)) - x, h: Math.max(...bs.map((b) => b.y + b.h)) - y };
402
+ }
382
403
  function drawSel() {
383
- if (!sel || !sel.el.isConnected) return removeSel();
404
+ if (sel) {
405
+ sel.els = sel.els.filter((e) => e.isConnected);
406
+ if (!sel.el.isConnected) sel.el = sel.els.at(-1);
407
+ }
408
+ if (!sel?.el) return removeSel();
384
409
  if (editing) return;
385
410
  if (!selBox) {
386
411
  selBox = document.createElement("div");
@@ -388,34 +413,46 @@
388
413
  selBox.innerHTML = '<span class="__sel-label"></span>' + HANDLES.map((h) => `<i data-h="${h}"></i>`).join("");
389
414
  }
390
415
  if (selBox.parentElement !== sel.sec) sel.sec.appendChild(selBox);
391
- const b = boxOf(sel.el, sel.sec);
416
+ const b = many() ? unionBox(sel.els, sel.sec) : boxOf(sel.el, sel.sec);
417
+ selBox.classList.toggle("multi", many());
418
+ for (const e of sel.els) e.classList.toggle("__member", many());
392
419
  Object.assign(selBox.style, { left: b.x + "px", top: b.y + "px", width: b.w + "px", height: b.h + "px" });
393
420
  selBox.classList.toggle("readonly", !editable);
394
421
  const lab = selBox.firstChild;
395
- lab.textContent = `${nameOf(sel.el, sel.sec)} ` + (drag?.kind === "move" ? `x ${b.x} y ${b.y}` : `${b.w} × ${b.h}`);
422
+ lab.textContent = `${many() ? sel.els.length + " elements" : nameOf(sel.el, sel.sec)} ` + (drag?.kind === "move" ? `x ${b.x} y ${b.y}` : `${b.w} × ${b.h}`);
396
423
  lab.className = "__sel-label " + (b.y < 30 ? "below" : "above");
397
424
  }
398
- function removeSel() { selBox?.remove(); document.querySelectorAll(".__selected").forEach((e) => e.classList.remove("__selected")); }
399
- function select(el, sec, notify = true) {
425
+ function removeSel() { selBox?.remove(); document.querySelectorAll(".__selected").forEach((e) => e.classList.remove("__selected", "__member")); }
426
+ function select(el, sec, notify = true, els = el ? [el] : []) {
400
427
  removeSel();
401
- sel = el ? { el, sec } : null;
402
- if (sel) { sel.el.classList.add("__selected"); drawSel(); }
428
+ sel = el ? { el, sec, els } : null;
429
+ if (sel) { sel.els.forEach((e) => e.classList.add("__selected")); drawSel(); }
403
430
  if (notify) host()?.selectionChanged(sel ? selectionInfo() : null);
404
431
  }
432
+ // ⌘-click: add an element to the selection, or take it out. An element and one inside it cannot move
433
+ // together (the inner one would move twice), so adding one drops the other.
434
+ function toggleSel(t) {
435
+ if (!sel || sel.sec !== t.sec) return select(t.el, t.sec);
436
+ const els = sel.els.includes(t.el) ? sel.els.filter((e) => e !== t.el)
437
+ : [...sel.els.filter((e) => !e.contains(t.el) && !t.el.contains(e)), t.el];
438
+ if (!els.length) return select(null, null);
439
+ select(els.includes(t.el) ? t.el : els.at(-1), t.sec, true, els);
440
+ }
405
441
  function selectionInfo() {
406
442
  const { el, sec } = sel;
407
- return { eid: el.dataset.eid, slide: slideInfo(sec), tag: tagOf(el), box: boxOf(el, sec), text: textOf(el).slice(0, 120) };
443
+ const info = (e) => ({ eid: e.dataset.eid, tag: tagOf(e), box: boxOf(e, sec) });
444
+ return { ...info(el), slide: slideInfo(sec), text: textOf(el).slice(0, 120),
445
+ ...(many() ? { also: sel.els.filter((e) => e !== el).map(info) } : {}) };
408
446
  }
409
- function selectEid(eid) {
410
- const el = eid && byEid(eid);
411
- const sec = el?.closest(SLIDE_OF);
412
- if (!el || sec !== deck.current()) return select(null, null);
413
- select(el, sec);
447
+ function selectEid(eids) {
448
+ const els = [].concat(eids || []).map((eid) => byEid(eid)).filter((e) => e?.closest(SLIDE_OF) === deck.current());
449
+ if (!els.length) return select(null, null);
450
+ select(els[0], deck.current(), true, els);
414
451
  }
415
452
 
416
453
  function selectHover(e) {
417
454
  const t = selectableOf(e.target);
418
- if (!t || (sel && t.el === sel.el)) return hideHover();
455
+ if (!t || sel?.els.includes(t.el)) return hideHover();
419
456
  if (!hoverBox) { hoverBox = document.createElement("div"); hoverBox.className = "__hover"; hoverBox.appendChild(document.createElement("span")); }
420
457
  const b = boxOf(t.el, t.sec);
421
458
  if (hoverBox.parentElement !== t.sec) t.sec.appendChild(hoverBox);
@@ -427,11 +464,11 @@
427
464
  }
428
465
 
429
466
  /* Snap targets: slide edges and centre, the footer rule, and the edges and centres of other elements */
430
- function snapTargets(sec, el) {
467
+ function snapTargets(sec, skip) {
431
468
  const xs = [0, W / 2, W], ys = [0, H / 2, H];
432
469
  if (FOOTER_TOP != null && sec.querySelector(".slide-foot")) ys.push(FOOTER_TOP);
433
470
  for (const o of sec.querySelectorAll("[data-eid]")) {
434
- if (o === el || el.contains(o) || o.contains(el)) continue;
471
+ if (skip.some((el) => o === el || el.contains(o) || o.contains(el))) continue;
435
472
  if (o.closest("svg") && o.tagName.toLowerCase() !== "svg") continue;
436
473
  if (!o.getClientRects().length) continue;
437
474
  const b = boxOf(o, sec);
@@ -472,30 +509,32 @@
472
509
  hideHover();
473
510
  // double click: edit the text under the pointer
474
511
  const now = performance.now();
475
- const dbl = now - lastDown.t < 450 && Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < 6 && lastDown.el === e.target;
512
+ const dbl = !(e.metaKey || e.ctrlKey) && now - lastDown.t < 450 && Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < 6 && lastDown.el === e.target;
476
513
  lastDown = { t: dbl ? 0 : now, x: e.clientX, y: e.clientY, el: e.target };
477
514
  if (dbl && !e.target.closest?.(".__sel i")) return editAt(e.target, e.clientX, e.clientY);
478
515
  const handle = e.target.closest?.(".__sel i[data-h]");
479
516
  if (!handle) {
480
517
  if (e.target.closest?.(".__pin")) return; // pin markers are removed in pin mode only
481
- const t = selectableOf(e.target);
482
- if (!t) return select(null, null);
518
+ const t = selectableOf(e.target), add = e.metaKey || e.ctrlKey;
519
+ if (!t) return add ? undefined : select(null, null);
483
520
  if (t.locked) {
484
- select(null, null);
521
+ if (!add) select(null, null); // a ⌘-click that misses keeps the group
485
522
  host()?.toast(`${t.el.classList.contains("slide-foot") ? "The footer is" : "This element is"} set by brand.css and locked.`);
486
523
  return;
487
524
  }
488
- if (!sel || sel.el !== t.el) select(t.el, t.sec);
525
+ if (add) return toggleSel(t);
526
+ if (!sel?.els.includes(t.el)) select(t.el, t.sec);
489
527
  }
490
- if (!editable) { if (handle) host()?.toast("Claude is working. Moving and resizing resume when it has finished."); return; }
528
+ if (!editable) { if (handle) host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished."); return; }
491
529
  const el = sel.el, cs = getComputedStyle(el);
492
530
  const { s } = frame(sel.sec);
493
531
  const [tx, ty] = translateOf(el);
532
+ const styles = (e) => ({ translate: e.style.translate, width: e.style.width, height: e.style.height, "max-width": e.style.maxWidth, "max-height": e.style.maxHeight });
494
533
  drag = {
495
534
  kind: handle ? "resize" : "move", handle: handle?.dataset.h, x0: e.clientX, y0: e.clientY, s, started: !!handle,
496
- b: boxOf(el, sel.sec), tx, ty, cw: parseFloat(cs.width) || 0, ch: parseFloat(cs.height) || 0,
497
- maxW: cs.maxWidth, maxH: cs.maxHeight, targets: snapTargets(sel.sec, el),
498
- before: { translate: el.style.translate, width: el.style.width, height: el.style.height, "max-width": el.style.maxWidth, "max-height": el.style.maxHeight },
535
+ b: many() ? unionBox(sel.els, sel.sec) : boxOf(el, sel.sec), tx, ty, cw: parseFloat(cs.width) || 0, ch: parseFloat(cs.height) || 0,
536
+ maxW: cs.maxWidth, maxH: cs.maxHeight, targets: snapTargets(sel.sec, sel.els),
537
+ items: sel.els.map((e) => { const [x, y] = translateOf(e); return { el: e, tx: x, ty: y, before: styles(e) }; }),
499
538
  };
500
539
  document.documentElement.setPointerCapture?.(e.pointerId);
501
540
  }
@@ -520,8 +559,8 @@
520
559
  const sy = snapTo([b.y + dy, b.y + dy + b.h / 2, b.y + dy + b.h], targets.ys, tol);
521
560
  if (sy) { dy += sy.d; gy.push(sy.at); }
522
561
  }
523
- el.style.translate = fmtTranslate(drag.tx + dx, drag.ty + dy);
524
- drag.label = `Moved ${nameOf(el, sel.sec)} by ${r1(dx)}, ${r1(dy)} px`;
562
+ for (const it of drag.items) it.el.style.translate = fmtTranslate(it.tx + dx, it.ty + dy);
563
+ drag.label = `Moved ${groupName()} by ${r1(dx)}, ${r1(dy)} px`;
525
564
  } else {
526
565
  const h = drag.handle;
527
566
  let w = b.w, ht = b.h, ox = 0, oy = 0; // ox/oy: how far the left/top edge moves
@@ -564,26 +603,30 @@
564
603
  clearGuides();
565
604
  if (!sel) return;
566
605
  drawSel();
567
- if (d.started) commit(d.before, d.label);
606
+ if (d.started) commit(d.items, d.label);
568
607
  }
569
- function commit(before, label) {
570
- const el = sel.el, set = {};
571
- const now = { translate: el.style.translate, width: el.style.width, height: el.style.height,
572
- "max-width": el.style.maxWidth, "max-height": el.style.maxHeight };
573
- for (const k of Object.keys(now)) if (now[k] !== before[k]) set[k] = now[k] || null;
574
- if (Object.keys(set).length) host()?.commitEdit({ eid: el.dataset.eid, set }, label);
608
+ // one edit (one undo step) for all the elements that changed
609
+ function commit(items, label) {
610
+ const ops = items.flatMap(({ el, before }) => {
611
+ const set = {}, now = { translate: el.style.translate, width: el.style.width, height: el.style.height,
612
+ "max-width": el.style.maxWidth, "max-height": el.style.maxHeight };
613
+ for (const k of Object.keys(now)) if (now[k] !== before[k]) set[k] = now[k] || null;
614
+ return Object.keys(set).length ? [{ eid: el.dataset.eid, set }] : [];
615
+ });
616
+ if (ops.length) host()?.commitEdit(ops, label);
575
617
  host()?.selectionChanged(selectionInfo());
576
618
  }
577
619
  function nudge(dx, dy) {
578
620
  if (!sel) return;
579
- if (!editable) return host()?.toast("Claude is working. Moving and resizing resume when it has finished.");
580
- const el = sel.el;
581
- const [tx, ty] = translateOf(el);
582
- el.style.translate = fmtTranslate(tx + dx, ty + dy);
621
+ if (!editable) return host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished.");
622
+ const ops = sel.els.map((el) => {
623
+ const [tx, ty] = translateOf(el);
624
+ el.style.translate = fmtTranslate(tx + dx, ty + dy);
625
+ return { eid: el.dataset.eid, set: { translate: el.style.translate || null } };
626
+ });
583
627
  drawSel();
584
628
  renderPins(pins);
585
- host()?.commitEdit({ eid: el.dataset.eid, set: { translate: el.style.translate || null } },
586
- { nudge: true, name: nameOf(el, sel.sec), dx, dy });
629
+ host()?.commitEdit(ops, { nudge: true, name: groupName(), dx, dy });
587
630
  host()?.selectionChanged(selectionInfo());
588
631
  }
589
632
  function setMode(m) {
@@ -641,12 +684,12 @@
641
684
  }
642
685
 
643
686
  async function startEdit(t, x, y, opts = {}) {
644
- if (!editable) return host()?.toast("Claude is working. Editing resumes when it has finished.");
687
+ if (!editable) return host()?.toast(lockReason || "Claude is working. Editing resumes when it has finished.");
645
688
  if (editing) await finishEdit(true);
646
689
  const src = opts.src ?? await host()?.textSource(t.eid, t.path);
647
690
  if (src == null || !t.el.isConnected) return;
648
691
  const el = t.el;
649
- if (!sel || sel.el !== el) select(el.dataset.eid ? el : null, t.sec);
692
+ if (!sel || sel.el !== el || many()) select(el.dataset.eid ? el : null, t.sec); // text editing is one element at a time
650
693
  removeSel();
651
694
  hideHover();
652
695
  editing = { ...t, rendered: el.innerHTML };
@@ -776,7 +819,7 @@
776
819
  // a new bullet left empty is removed again
777
820
  if (ed.isNew && isBlank(el)) {
778
821
  el.remove();
779
- if (sel?.el === el) select(null, null);
822
+ if (sel?.els.includes(el)) select(null, null);
780
823
  host()?.commitRemove(eid);
781
824
  chainDirty = false;
782
825
  return;
@@ -1087,7 +1130,7 @@
1087
1130
  isEditing: () => !!editing,
1088
1131
  deselect() { select(null, null); },
1089
1132
  hasSelection: () => !!sel,
1090
- setEditable(on) { editable = !!on; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
1133
+ setEditable(on, why = null) { editable = !!on; lockReason = why; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
1091
1134
  renderPins,
1092
1135
  async measure(index) {
1093
1136
  goto(index);
@@ -1136,7 +1179,7 @@
1136
1179
  const k = e.key, meta = e.metaKey || e.ctrlKey;
1137
1180
  if (sel && mode === "select" && !meta && (k === "Delete" || k === "Backspace")) {
1138
1181
  e.preventDefault(); e.stopImmediatePropagation();
1139
- host()?.requestDelete(sel.el.dataset.eid);
1182
+ host()?.requestDelete(sel.els.map((e) => e.dataset.eid));
1140
1183
  return;
1141
1184
  }
1142
1185
  // arrows nudge the selection (Shift: 10 px) instead of changing slides