pindeck 4.0.0-beta.1 → 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/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  What each PinDeck version brought, newest first. Releases are on [GitHub](https://github.com/ehabets/PinDeck/releases).
4
4
 
5
5
 
6
+ * **4.1.0-beta.1**: select several elements at once.
7
+ - `⌘`-click selects several elements on a slide: they move and nudge together (one undo step), `Delete` removes them after one confirmation, and the assistant gets all of them as "these".
8
+ - `cli.mjs element <eid>...` prints elements' HTML as written in the deck, so the assistant can read one element of a long slide line.
9
+ - The PinDeck version shows beside the logo.
10
+ - A "Created by PinDeck" badge with a QR code for a closing slide, in light and dark (see the README).
11
+
6
12
  * **4.0.0-beta.1**: review comments, and Quarto decks.
7
13
  - **Comment** keeps a note on a pinned element or a slide for later, outside the deck. Comments are found again after changes or flagged, listed in the Comments panel, sent to the assistant as one request, and written as a review packet for an agent outside PinDeck. After Sebastian J. Schlecht's review mode.
8
14
  - `pindeck talk.qmd` opens a Quarto deck: the assistant edits the source, PinDeck renders after every change, and hand edits of the output are off. An example deck in `quarto-mathjax-example/`.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # PinDeck
2
2
 
3
- > **macOS and Linux · Version 4 beta preview** (`4.0.0-beta.1`). The beta is on npm’s `beta` channel and the [`v4-beta`](https://github.com/ehabets/PinDeck/tree/v4-beta) branch; the stable release is 3.4.0 (`npx pindeck`, on [`main`](https://github.com/ehabets/PinDeck)). On Linux, PinDeck runs as a headless server that you use from your Mac's browser through an SSH tunnel (see [Running on a Linux server](docs/linux-server.md#running-on-a-linux-server)). Windows is not supported. The beta keeps its settings and chats in `~/.pindeck-v4`, apart from 3.x (`~/.pindeck-v3`) and 2.x (`~/.pindeck`) (see [Models, providers and configuration](docs/configuration.md#models-providers-and-configuration)).
3
+ > **macOS and Linux · Version 4 beta preview** (`4.1.0-beta.1`). The beta is on npm’s `beta` channel and the [`v4-beta`](https://github.com/ehabets/PinDeck/tree/v4-beta) branch; the stable release is 3.4.0 (`npx pindeck`, on [`main`](https://github.com/ehabets/PinDeck)). On Linux, PinDeck runs as a headless server that you use from your Mac's browser through an SSH tunnel (see [Running on a Linux server](docs/linux-server.md#running-on-a-linux-server)). Windows is not supported. The beta keeps its settings and chats in `~/.pindeck-v4`, apart from 3.x (`~/.pindeck-v3`) and 2.x (`~/.pindeck`) (see [Models, providers and configuration](docs/configuration.md#models-providers-and-configuration)).
4
4
 
5
5
  **Pin it. Say it. Done.** PinDeck is a local editor for HTML slide decks (reveal.js): the slides on the left, the slide in the middle, and a chat with Claude Code or OpenAI Codex on the right. You click elements to pin them, write what to change, and the selected assistant edits the HTML and CSS. The slide updates in place.
6
6
 
@@ -26,7 +26,7 @@ Install the beta from npm:
26
26
  ```bash
27
27
  npx pindeck@beta /path/to/deck
28
28
  # Or select this exact beta:
29
- npx pindeck@4.0.0-beta.1 /path/to/deck
29
+ npx pindeck@4.1.0-beta.1 /path/to/deck
30
30
  ```
31
31
 
32
32
  Or run it from the `v4-beta` branch, which includes the example decks:
@@ -66,6 +66,12 @@ Codex integration uses OpenAI’s experimental `dynamicTools` extension to the a
66
66
  * [How it works](docs/how-it-works.md): the architecture, the files, and the current limitations.
67
67
  * [Changelog](CHANGELOG.md): what each version brought.
68
68
 
69
+ ## "Created by PinDeck" badge
70
+
71
+ Made your slides with PinDeck? Feel free to put the badge on a closing slide. Its QR code links to this repository. Use the
72
+ [light](docs/badge/created-by-pindeck-light.svg) or [dark](docs/badge/created-by-pindeck-dark.svg) SVG, or the
73
+ [light](docs/badge/created-by-pindeck-light.png) or [dark](docs/badge/created-by-pindeck-dark.png) PNG. To keep the code easy to scan, show it at least 300 px wide.
74
+
69
75
  ## Contributing
70
76
 
71
77
  Bug reports, ideas, example decks and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) explains how to report a bug, set up PinDeck for development, test a change by hand, and what a pull request should contain. In short: no npm dependencies, deck files are edited only where they must change, both backends’ permissions stay narrow, and the README describes what users see.
package/cli.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  (add --issues for the issues only: layout 9 10 11 --issues)
6
6
  node cli.mjs shot <slide>... screenshots with pin markers; prints the PNG paths
7
7
  node cli.mjs slides slide numbers and ids
8
+ node cli.mjs element <eid>... the HTML of elements, by data-eid, as written in the deck
8
9
  node cli.mjs pdf handout PDF with one page per slide (build steps in their final state)
9
10
  node cli.mjs pdf presentation PDF with one page per build step
10
11
  (add "all" to include hidden slides: pdf handout all)
@@ -64,8 +65,8 @@ if (cmd === "git-setup") {
64
65
  }
65
66
  const issuesOnly = cmd === "layout" && rest.includes("--issues");
66
67
  const slides = rest.filter((a) => a !== "--issues");
67
- if (!["layout", "shot", "slides", "pdf", "pdf-figure", "resolve", "reply"].includes(cmd)) {
68
- console.error("Usage: node cli.mjs layout|shot <slide>... [--issues] | node cli.mjs slides | node cli.mjs pdf handout|presentation | " +
68
+ if (!["layout", "shot", "slides", "element", "pdf", "pdf-figure", "resolve", "reply"].includes(cmd)) {
69
+ console.error("Usage: node cli.mjs layout|shot <slide>... [--issues] | node cli.mjs slides | node cli.mjs element <eid>... | node cli.mjs pdf handout|presentation | " +
69
70
  "node cli.mjs resolve <comment> [what was done] | node cli.mjs reply <comment> <text> | " +
70
71
  "node cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px] | node cli.mjs git-setup [deck]");
71
72
  process.exit(2);
@@ -96,6 +97,7 @@ if (!url || !token) {
96
97
  const queries = cmd === "pdf-figure" ? [figureQuery([...rest])]
97
98
  : cmd === "pdf" ? [{ slide: rest[0] || "handout", ...(rest[1] === "all" ? { all: "1" } : {}) }]
98
99
  : cmd === "resolve" || cmd === "reply" ? [{ id: rest[0] || "", note: rest.slice(1).join(" ") }]
100
+ : cmd === "element" ? (rest.length ? rest : [""]).map((eid) => ({ eid }))
99
101
  : cmd === "slides" ? [{}] : (slides.length ? slides : ["current"]).map((slide) => ({ slide }));
100
102
  let ok = true;
101
103
  for (const q of queries) {
@@ -24,7 +24,7 @@
24
24
  | `server.mjs` | The server for one deck: UI, deck files, events, backend session, undo, CLI endpoints. Behind the front, or on its own (`node server.mjs <deck>`). |
25
25
  | `lib/login.mjs`, `lib/deck-target.mjs`, `lib/deck-list.mjs` | Login links and cookies, which deck a path names (and its state folder), and the decks the menu lists. |
26
26
  | `lib/servers.mjs` | The PinDecks running for this user, and which one serves a deck (`pindeck login`, `cli.mjs`). |
27
- | `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
27
+ | `cli.mjs` | `layout`, `shot`, `slides`, `element`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
28
28
  | `public/` | The editor page; `bridge.js`, which is injected into the deck in edit mode (never saved to it); `events-worker.js`, the event stream the tabs of a browser share. |
29
29
  | `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
30
30
  | `lib/pptx-import.mjs` | Turns PowerPoint slides into slides of the deck: figures, tables, build steps, audio, layout, HTML in the deck's style. |
package/docs/using.md CHANGED
@@ -12,11 +12,11 @@ A deck is open in one PinDeck at a time: started again for a deck that another P
12
12
 
13
13
  | Action | How |
14
14
  |---|---|
15
- | Select, move, resize | Select mode (`V`, the default): click an element to select it, drag to move, pull a handle to resize. Elements snap to the slide edges and centre, the footer rule and other elements (pink guides); hold `Alt` to turn snapping off, `Shift` to move along one axis. Images keep their aspect ratio (`Shift` toggles). Arrow keys nudge by 1 px, `Shift`+arrows by 10 px. `Delete` removes the selected element after a confirmation; a picture goes with its caption, and files nothing else in the deck uses can be deleted with it. `⌘Z` brings both back. |
15
+ | Select, move, resize | Select mode (`V`, the default): click an element to select it, drag to move, pull a handle to resize. Elements snap to the slide edges and centre, the footer rule and other elements (pink guides); hold `Alt` to turn snapping off, `Shift` to move along one axis. Images keep their aspect ratio (`Shift` toggles). Arrow keys nudge by 1 px, `Shift`+arrows by 10 px. `⌘`-click adds an element to the selection or takes it out: the elements move, nudge and are deleted together (they have no resize handles), and the assistant gets all of them. `Delete` removes the selected element after a confirmation; a picture goes with its caption, and files nothing else in the deck uses can be deleted with it. `⌘Z` brings both back. |
16
16
  | Edit text | Double-click the text (or select it and press `Enter`). The element shows its source, so math appears as TeX, e.g. `\(\mu=0.5\)`. `Enter` or a click elsewhere saves, `Esc` cancels, `Shift+Enter` adds a line break, `⌘B` / `⌘I` make text bold or italic. In a bullet, `Enter` splits it at the cursor and continues in a new bullet below; `Enter` on an empty new bullet removes it and stops. Pasted text arrives as plain text. Table cells are edited one at a time. |
17
17
  | Undo, redo | `⌘Z` / `⇧⌘Z` or the toolbar arrows. One history for your hand edits and the assistant's changes. |
18
18
  | Pin elements | Press `P` (or the Pin button), click elements; each click gets a number. Click a marker to remove it, `Esc` to stop. |
19
- | Ask the assistant | Write in the sidebar and refer to pins by number: "1: align with 2, make 3 smaller". The selected element is sent too, so "make this smaller" works. `Enter` sends, `Shift+Enter` adds a new line. |
19
+ | Ask the assistant | Write in the sidebar and refer to pins by number: "1: align with 2, make 3 smaller". The selected element is sent too, so "make this smaller" works; with several selected, "align these" does. `Enter` sends, `Shift+Enter` adds a new line. |
20
20
  | Visual questions | **+** in the chat, then **Screenshot**: the current slide with the pins of that message is attached when you send. If Chrome cannot take it, the assistant is told. **Stop** during the screenshot stops at once. |
21
21
  | Review | Each answer ends with the changed files and a **Diff** button; the most recent change still in effect also has an **Undo** button. After a restart, earlier changes keep their *undone* mark but have no Diff or Undo (the undo history ends with the run). |
22
22
  | Stop | **Stop** interrupts the assistant; the conversation continues. **New chat** starts a new conversation; your slides, Undo and Redo stay as they are. |
@@ -13,7 +13,7 @@ export const deckTools = [
13
13
  { type: 'function', name: 'pindeck_read', description: 'Read a deck text file, attached image, screenshot, or PDF page. For PDFs supply a 1-based page; returns page text and its rendered image. For text use offset to read beyond the first 120000 characters.', inputSchema: { ...schema({ path: str, page: { type: 'integer', minimum: 1 }, offset: { type: 'integer', minimum: 0 } }), required: ['path'] } },
14
14
  { type: 'function', name: 'pindeck_edit', description: 'Replace one exact unique text occurrence in a file of the deck folder: the deck file, theme.css or another of its files. Read the file first. brand.css, vendor/, .git/ and other decks in the folder are locked.', inputSchema: { ...schema({ path: str, oldText: str, newText: str }), required: ['path', 'oldText', 'newText'] } },
15
15
  { type: 'function', name: 'pindeck_write', description: 'Create a new file in the deck folder (missing folders are created), or replace all of an existing one. Prefer pindeck_edit for changes to existing files. The same files are locked as for pindeck_edit.', inputSchema: { ...schema({ path: str, content: str }), required: ['path', 'content'] } },
16
- { type: 'function', name: 'pindeck_command', description: 'Run a PinDeck command: layout, shot, slides, pdf, pdf-figure, resolve (a review comment: its id, then what you did) or reply (to a review comment you leave open: its id, then why). Pass individual arguments in an array, never a shell command. Use pindeck_read on returned image paths.', inputSchema: { ...schema({ command: { enum: ['layout', 'shot', 'slides', 'pdf', 'pdf-figure', 'resolve', 'reply'] }, args: { type: 'array', items: str, maxItems: 100 } }), required: ['command', 'args'] } },
16
+ { type: 'function', name: 'pindeck_command', description: 'Run a PinDeck command: layout, shot, slides, element (the HTML of elements, by data-eid), pdf, pdf-figure, resolve (a review comment: its id, then what you did) or reply (to a review comment you leave open: its id, then why). Pass individual arguments in an array, never a shell command. Use pindeck_read on returned image paths.', inputSchema: { ...schema({ command: { enum: ['layout', 'shot', 'slides', 'element', 'pdf', 'pdf-figure', 'resolve', 'reply'] }, args: { type: 'array', items: str, maxItems: 100 } }), required: ['command', 'args'] } },
17
17
  ];
18
18
  /* Codex may read the deck, screenshots, attached PDFs and asset roots, and write any file of the deck folder
19
19
  except the locked ones: the same rules as Claude's --allowedTools / --disallowedTools. locked: patterns
@@ -47,7 +47,7 @@ export function createDeckTools({ deckDir, readDirs = [], cliFile, env, pdfTextF
47
47
  return full;
48
48
  }
49
49
  async function command(name, args, signal) {
50
- if (!['layout', 'shot', 'slides', 'pdf', 'pdf-figure', 'resolve', 'reply'].includes(name) || !Array.isArray(args) || args.length > 100 || args.some(a => typeof a !== 'string' || a.length > 4096 || a.includes('\0'))) throw new Error('Invalid PinDeck command.');
50
+ if (!['layout', 'shot', 'slides', 'element', 'pdf', 'pdf-figure', 'resolve', 'reply'].includes(name) || !Array.isArray(args) || args.length > 100 || args.some(a => typeof a !== 'string' || a.length > 4096 || a.includes('\0'))) throw new Error('Invalid PinDeck command.');
51
51
  if (name === 'pdf-figure') {
52
52
  checked(args[0]);
53
53
  // Host renderer can create figures: disallow symlinked output directories too.
package/lib/eids.mjs CHANGED
@@ -433,6 +433,12 @@ export function getInner(html, eid, path) {
433
433
  return node ? html.slice(node.contentStart, node.closeStart) : null;
434
434
  }
435
435
 
436
+ /* An element with its own tags, exactly as written in the source. */
437
+ export function getOuter(html, eid) {
438
+ const node = nodeAt(html, eid, [], { anyKind: true });
439
+ return node ? html.slice(node.start, elementEnd(html, node)) : null;
440
+ }
441
+
436
442
  /* Replaces the inner HTML of one element. Returns { html, changed } or null if not found. */
437
443
  export function setInner(html, eid, path, inner) {
438
444
  const node = nodeAt(html, eid, path);
@@ -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
@@ -100,7 +101,8 @@ inline styles on the element in {{DECK}}: `translate: Xpx Ypx` for moves, `width
100
101
  keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
101
102
  The user can also edit text in place (double-click); you get a note with the new text. Keep their
102
103
  wording unless asked to change it.
103
- 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.
104
106
 
105
107
  ## Replies
106
108
  Replies appear in a narrow sidebar. Keep them short: 1 to 4 sentences saying what you changed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "4.0.0-beta.1",
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",
package/public/bridge.js CHANGED
@@ -67,7 +67,8 @@
67
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; }
68
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; }
69
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; }
70
- .__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; }
71
72
  .__sel-label { position:absolute; left:calc(-1.5px*var(--pindeck-u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
72
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));
73
74
  border-radius:calc(3px*var(--pindeck-u,1)); font-variant-numeric:tabular-nums; }
@@ -389,8 +390,22 @@
389
390
  const fmtTranslate = (x, y) => (r1(x) || r1(y) ? `${r1(x)}px ${r1(y)}px` : "");
390
391
  const nameOf = (el, sec) => { const e = el.dataset.eid || ""; return e.startsWith(sec.dataset.eid + "/") ? e.slice(sec.dataset.eid.length) : e; };
391
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
+ }
392
403
  function drawSel() {
393
- 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();
394
409
  if (editing) return;
395
410
  if (!selBox) {
396
411
  selBox = document.createElement("div");
@@ -398,34 +413,46 @@
398
413
  selBox.innerHTML = '<span class="__sel-label"></span>' + HANDLES.map((h) => `<i data-h="${h}"></i>`).join("");
399
414
  }
400
415
  if (selBox.parentElement !== sel.sec) sel.sec.appendChild(selBox);
401
- 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());
402
419
  Object.assign(selBox.style, { left: b.x + "px", top: b.y + "px", width: b.w + "px", height: b.h + "px" });
403
420
  selBox.classList.toggle("readonly", !editable);
404
421
  const lab = selBox.firstChild;
405
- 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}`);
406
423
  lab.className = "__sel-label " + (b.y < 30 ? "below" : "above");
407
424
  }
408
- function removeSel() { selBox?.remove(); document.querySelectorAll(".__selected").forEach((e) => e.classList.remove("__selected")); }
409
- 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] : []) {
410
427
  removeSel();
411
- sel = el ? { el, sec } : null;
412
- 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(); }
413
430
  if (notify) host()?.selectionChanged(sel ? selectionInfo() : null);
414
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
+ }
415
441
  function selectionInfo() {
416
442
  const { el, sec } = sel;
417
- 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) } : {}) };
418
446
  }
419
- function selectEid(eid) {
420
- const el = eid && byEid(eid);
421
- const sec = el?.closest(SLIDE_OF);
422
- if (!el || sec !== deck.current()) return select(null, null);
423
- 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);
424
451
  }
425
452
 
426
453
  function selectHover(e) {
427
454
  const t = selectableOf(e.target);
428
- if (!t || (sel && t.el === sel.el)) return hideHover();
455
+ if (!t || sel?.els.includes(t.el)) return hideHover();
429
456
  if (!hoverBox) { hoverBox = document.createElement("div"); hoverBox.className = "__hover"; hoverBox.appendChild(document.createElement("span")); }
430
457
  const b = boxOf(t.el, t.sec);
431
458
  if (hoverBox.parentElement !== t.sec) t.sec.appendChild(hoverBox);
@@ -437,11 +464,11 @@
437
464
  }
438
465
 
439
466
  /* Snap targets: slide edges and centre, the footer rule, and the edges and centres of other elements */
440
- function snapTargets(sec, el) {
467
+ function snapTargets(sec, skip) {
441
468
  const xs = [0, W / 2, W], ys = [0, H / 2, H];
442
469
  if (FOOTER_TOP != null && sec.querySelector(".slide-foot")) ys.push(FOOTER_TOP);
443
470
  for (const o of sec.querySelectorAll("[data-eid]")) {
444
- if (o === el || el.contains(o) || o.contains(el)) continue;
471
+ if (skip.some((el) => o === el || el.contains(o) || o.contains(el))) continue;
445
472
  if (o.closest("svg") && o.tagName.toLowerCase() !== "svg") continue;
446
473
  if (!o.getClientRects().length) continue;
447
474
  const b = boxOf(o, sec);
@@ -482,30 +509,32 @@
482
509
  hideHover();
483
510
  // double click: edit the text under the pointer
484
511
  const now = performance.now();
485
- 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;
486
513
  lastDown = { t: dbl ? 0 : now, x: e.clientX, y: e.clientY, el: e.target };
487
514
  if (dbl && !e.target.closest?.(".__sel i")) return editAt(e.target, e.clientX, e.clientY);
488
515
  const handle = e.target.closest?.(".__sel i[data-h]");
489
516
  if (!handle) {
490
517
  if (e.target.closest?.(".__pin")) return; // pin markers are removed in pin mode only
491
- const t = selectableOf(e.target);
492
- 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);
493
520
  if (t.locked) {
494
- select(null, null);
521
+ if (!add) select(null, null); // a ⌘-click that misses keeps the group
495
522
  host()?.toast(`${t.el.classList.contains("slide-foot") ? "The footer is" : "This element is"} set by brand.css and locked.`);
496
523
  return;
497
524
  }
498
- 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);
499
527
  }
500
528
  if (!editable) { if (handle) host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished."); return; }
501
529
  const el = sel.el, cs = getComputedStyle(el);
502
530
  const { s } = frame(sel.sec);
503
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 });
504
533
  drag = {
505
534
  kind: handle ? "resize" : "move", handle: handle?.dataset.h, x0: e.clientX, y0: e.clientY, s, started: !!handle,
506
- b: boxOf(el, sel.sec), tx, ty, cw: parseFloat(cs.width) || 0, ch: parseFloat(cs.height) || 0,
507
- maxW: cs.maxWidth, maxH: cs.maxHeight, targets: snapTargets(sel.sec, el),
508
- 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) }; }),
509
538
  };
510
539
  document.documentElement.setPointerCapture?.(e.pointerId);
511
540
  }
@@ -530,8 +559,8 @@
530
559
  const sy = snapTo([b.y + dy, b.y + dy + b.h / 2, b.y + dy + b.h], targets.ys, tol);
531
560
  if (sy) { dy += sy.d; gy.push(sy.at); }
532
561
  }
533
- el.style.translate = fmtTranslate(drag.tx + dx, drag.ty + dy);
534
- 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`;
535
564
  } else {
536
565
  const h = drag.handle;
537
566
  let w = b.w, ht = b.h, ox = 0, oy = 0; // ox/oy: how far the left/top edge moves
@@ -574,26 +603,30 @@
574
603
  clearGuides();
575
604
  if (!sel) return;
576
605
  drawSel();
577
- if (d.started) commit(d.before, d.label);
606
+ if (d.started) commit(d.items, d.label);
578
607
  }
579
- function commit(before, label) {
580
- const el = sel.el, set = {};
581
- const now = { translate: el.style.translate, width: el.style.width, height: el.style.height,
582
- "max-width": el.style.maxWidth, "max-height": el.style.maxHeight };
583
- for (const k of Object.keys(now)) if (now[k] !== before[k]) set[k] = now[k] || null;
584
- 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);
585
617
  host()?.selectionChanged(selectionInfo());
586
618
  }
587
619
  function nudge(dx, dy) {
588
620
  if (!sel) return;
589
621
  if (!editable) return host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished.");
590
- const el = sel.el;
591
- const [tx, ty] = translateOf(el);
592
- el.style.translate = fmtTranslate(tx + dx, ty + dy);
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
+ });
593
627
  drawSel();
594
628
  renderPins(pins);
595
- host()?.commitEdit({ eid: el.dataset.eid, set: { translate: el.style.translate || null } },
596
- { nudge: true, name: nameOf(el, sel.sec), dx, dy });
629
+ host()?.commitEdit(ops, { nudge: true, name: groupName(), dx, dy });
597
630
  host()?.selectionChanged(selectionInfo());
598
631
  }
599
632
  function setMode(m) {
@@ -656,7 +689,7 @@
656
689
  const src = opts.src ?? await host()?.textSource(t.eid, t.path);
657
690
  if (src == null || !t.el.isConnected) return;
658
691
  const el = t.el;
659
- 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
660
693
  removeSel();
661
694
  hideHover();
662
695
  editing = { ...t, rendered: el.innerHTML };
@@ -786,7 +819,7 @@
786
819
  // a new bullet left empty is removed again
787
820
  if (ed.isNew && isBlank(el)) {
788
821
  el.remove();
789
- if (sel?.el === el) select(null, null);
822
+ if (sel?.els.includes(el)) select(null, null);
790
823
  host()?.commitRemove(eid);
791
824
  chainDirty = false;
792
825
  return;
@@ -1146,7 +1179,7 @@
1146
1179
  const k = e.key, meta = e.metaKey || e.ctrlKey;
1147
1180
  if (sel && mode === "select" && !meta && (k === "Delete" || k === "Backspace")) {
1148
1181
  e.preventDefault(); e.stopImmediatePropagation();
1149
- host()?.requestDelete(sel.el.dataset.eid);
1182
+ host()?.requestDelete(sel.els.map((e) => e.dataset.eid));
1150
1183
  return;
1151
1184
  }
1152
1185
  // arrows nudge the selection (Shift: 10 px) instead of changing slides
package/public/editor.css CHANGED
@@ -33,6 +33,8 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
33
33
  .brand-pin { width: 14px; height: 14px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--orange);
34
34
  box-shadow: inset 0 0 0 4px var(--orange), inset 0 0 0 7px #fff; }
35
35
 
36
+ .version { font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
37
+
36
38
  .app { display: grid; grid-template-columns: 220px minmax(0, 1fr) 400px; height: 100vh; }
37
39
  .pane-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: 44px;
38
40
  padding: 0 14px; font-weight: 600; border-bottom: 1px solid var(--line); background: var(--panel); flex: none; }
package/public/editor.js CHANGED
@@ -125,7 +125,7 @@ const editorHost = {
125
125
  applyEditable(bridge);
126
126
  refreshPinsFromSlides();
127
127
  bridge.renderPins(allPins());
128
- if (state.sel) bridge.selectEid(state.sel.eid);
128
+ if (state.sel) bridge.selectEid([state.sel.eid, ...(state.sel.also || []).map((a) => a.eid)]);
129
129
  if (state.pendingTitleEdit) { state.pendingTitleEdit = false; setTimeout(() => bridge.editTitle(), 50); }
130
130
  renderSlides();
131
131
  renderToolbar();
@@ -215,7 +215,7 @@ function queueEdit(fn, failMsg = "Could not save the change.") {
215
215
  }
216
216
  function postEdit(op, label) {
217
217
  return queueEdit(async () => {
218
- const res = await api("edit", { ops: [op], label });
218
+ const res = await api("edit", { ops: [].concat(op), label });
219
219
  if (!res.ok) {
220
220
  toast((await res.json().catch(() => ({}))).error || "Could not save the change.");
221
221
  loadMain(); // show the file as it is
@@ -231,8 +231,9 @@ function flushNudge() {
231
231
  }
232
232
  function commitEdit(op, label) {
233
233
  if (label?.nudge) { // arrow keys: one change per burst of key presses
234
- if (nudgeAcc && nudgeAcc.op.eid !== op.eid) flushNudge();
235
- nudgeAcc ??= { name: label.name, dx: 0, dy: 0 };
234
+ const key = [].concat(op).map((o) => o.eid).join(" "); // a group nudge is one op per element
235
+ if (nudgeAcc && nudgeAcc.key !== key) flushNudge();
236
+ nudgeAcc ??= { key, name: label.name, dx: 0, dy: 0 };
236
237
  nudgeAcc.dx += label.dx; nudgeAcc.dy += label.dy; nudgeAcc.op = op;
237
238
  clearTimeout(nudgeTimer);
238
239
  nudgeTimer = setTimeout(flushNudge, 600);
@@ -658,8 +659,8 @@ function shortEid(p) {
658
659
  function renderPinTray() {
659
660
  const sel = state.sel;
660
661
  $("pin-tray").innerHTML = (sel ? `
661
- <span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. The assistant sees it as “this”.">
662
- <span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
662
+ <span class="chip sel" data-sel title="Selected: ${esc([sel, ...(sel.also || [])].map((s) => s.eid).join(", "))}. The assistant sees ${sel.also?.length ? "them as “these”" : "it as “this”"}.">
663
+ <span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))}${sel.also?.length ? ` +${sel.also.length}` : ""} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
663
664
  <button class="x" data-unsel aria-label="Deselect">×</button></span>` : "") + state.pins.map((p) => `
664
665
  <span class="chip" data-n="${p.n}" title="${esc(p.eid)}${p.text ? " — " + esc(p.text.slice(0, 80)) : ""}">
665
666
  <span class="n">${p.n}</span> S${p.slide.index + 1} · ${esc(shortEid(p))}${p.word ? ` “${esc(p.word)}”` : ""}
@@ -1048,7 +1049,7 @@ function handleKey(k, mod = {}) {
1048
1049
  if (!(state.sel && state.mode === "select" && main)) $("input").focus();
1049
1050
  else main.bridge.editSelection().then((editing) => { if (!editing) $("input").focus(); });
1050
1051
  }
1051
- else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete(state.sel.eid);
1052
+ else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete([state.sel.eid, ...(state.sel.also || []).map((a) => a.eid)]);
1052
1053
  else if (k.startsWith("Arrow") && state.sel && state.mode === "select") {
1053
1054
  const step = mod.shift ? 10 : 1;
1054
1055
  main?.bridge.nudge(k === "ArrowLeft" ? -step : k === "ArrowRight" ? step : 0, k === "ArrowUp" ? -step : k === "ArrowDown" ? step : 0);
@@ -1408,22 +1409,24 @@ $("provider-switch").addEventListener("close", async () => {
1408
1409
  with it (ticked by default). Undo brings back the element and the files. */
1409
1410
  let deleting = false;
1410
1411
  async function requestDelete(eid) {
1411
- if (deleting || !eid) return;
1412
+ const eids = [].concat(eid || []);
1413
+ if (deleting || !eids.length) return;
1412
1414
  if (quartoWhy()) return toast(quartoWhy());
1413
1415
  if (state.busy) return toast("The assistant is working. Wait until it has finished.");
1414
1416
  deleting = true;
1415
1417
  try {
1416
1418
  flushNudge();
1417
1419
  await editChain;
1418
- const res = await api(`delete-info?${new URLSearchParams({ eid })}`);
1420
+ const res = await api(`delete-info?${new URLSearchParams(eids.map((e) => ["eid", e]))}`);
1419
1421
  const info = await res.json().catch(() => ({}));
1420
1422
  if (!res.ok) return toast(info.error || "Cannot delete this element.");
1421
- const name = info.eid.replace(/^[^/]+\//, "/");
1423
+ const many = info.targets.length > 1;
1424
+ const name = info.targets.map((t) => t.eid.replace(/^[^/]+\//, "/")).join(", ");
1422
1425
  const slide = state.slides.findIndex((s) => info.eid === s.eid || info.eid.startsWith(s.eid + "/"));
1423
- const isFig = info.tag.startsWith("figure") && info.eid !== eid;
1424
- $("delete-title").textContent = isFig ? "Delete this figure?" : `Delete ${name}?`;
1425
- $("delete-what").textContent = `${isFig ? `The picture and its caption (${name})` : info.tag}` +
1426
- (slide >= 0 ? ` on slide ${slide + 1}` : "") + " will be removed from the slide.";
1426
+ const isFig = !many && info.tag.startsWith("figure") && info.eid !== eids[0];
1427
+ $("delete-title").textContent = many ? `Delete ${info.targets.length} elements?` : isFig ? "Delete this figure?" : `Delete ${name}?`;
1428
+ $("delete-what").textContent = `${many ? `${name}` : isFig ? `The picture and its caption (${name})` : info.tag}` +
1429
+ (slide >= 0 ? ` on slide ${slide + 1}` : "") + ` will be removed from the slide.`;
1427
1430
  $("delete-files").innerHTML = info.files.map((f, i) => f.usedElsewhere
1428
1431
  ? `<div class="file kept"><span>📄</span><span><code>${esc(f.rel)}</code>
1429
1432
  <span class="why">${f.usedBy?.length ? `Also used by ${esc(f.usedBy.join(", "))}` : "Still used elsewhere in the deck"}, so it is kept.</span></span></div>`
@@ -1438,7 +1441,7 @@ async function requestDelete(eid) {
1438
1441
  });
1439
1442
  if (choice !== "delete") return;
1440
1443
  const files = [...$("delete-files").querySelectorAll("input[data-file]:checked")].map((c) => c.dataset.file);
1441
- const r2 = await api("delete", { eid, files });
1444
+ const r2 = await api("delete", { eids, files });
1442
1445
  const d2 = await r2.json().catch(() => ({}));
1443
1446
  if (!r2.ok) return toast(d2.error || "Could not delete the element.");
1444
1447
  main?.bridge.deselect();
package/public/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  <!-- Left: slides -->
16
16
  <nav class="slides-pane" aria-label="Slides">
17
- <div class="pane-head"><span class="brand"><span class="brand-pin" aria-hidden="true"></span>PinDeck</span></div>
17
+ <div class="pane-head"><span class="brand"><span class="brand-pin" aria-hidden="true"></span>PinDeck</span><span class="version muted">v{{VERSION}}</span></div>
18
18
  <div class="slides-actions menu-wrap">
19
19
  <button id="add-slide-btn" class="add-slide" aria-haspopup="menu" aria-expanded="false" title="Add a slide after the current one">+ New slide</button>
20
20
  <div id="add-slide-menu" class="menu left" role="menu" hidden>
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, execFileSync } 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, decode } from "./lib/eids.mjs";
26
+ import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, getOuter, 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";
@@ -51,6 +51,7 @@ import { indexDeck, commentFromPin, locate, loadReview, saveReview, packet, requ
51
51
  prints the links; this server tells it its address over IPC. */
52
52
  const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
53
53
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
54
+ const VERSION = JSON.parse(fs.readFileSync(path.join(EDITOR_DIR, "package.json"), "utf8")).version;
54
55
  const argv = process.argv.slice(2);
55
56
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
56
57
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
@@ -698,7 +699,7 @@ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?#
698
699
  Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
699
700
  Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
700
701
  Use pindeck_edit for exact replacements in ${DECK_FILE}, theme.css and the deck's other files, and pindeck_write to create a new file in the deck folder. Never change brand.css.
701
- Use pindeck_command with separate command and args for layout, shot, slides, pdf, pdf-figure, resolve or reply.
702
+ Use pindeck_command with separate command and args for layout, shot, slides, element (the HTML of data-eid ids), pdf, pdf-figure, resolve or reply.
702
703
  Do not use shell commands, native file edits, or other tools to bypass these restrictions.
703
704
  When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
704
705
  Read README.md and the deck instructions below for conventions. Use layout after every visual change.
@@ -730,7 +731,7 @@ function makeSession(choice) {
730
731
  "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
731
732
  "--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
732
733
  "--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
733
- ...["layout", "shot", "slides", "pdf", "pdf-figure", "resolve", "reply"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
734
+ ...["layout", "shot", "slides", "element", "pdf", "pdf-figure", "resolve", "reply"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
734
735
  "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
735
736
  ...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
736
737
  });
@@ -904,7 +905,7 @@ function toolSummary(name, input = {}) {
904
905
  // must show as the script, or a refused script looks like a refused layout check
905
906
  const cmd = String(input.command || "").trim();
906
907
  const m = cmd.startsWith(CLI_CMD + " ") && cmd.slice(CLI_CMD.length).match(/^\s+([\w-]+)\s*([^\n;&|<>`$]*)$/);
907
- const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure", resolve: "Resolve comment", reply: "Reply to comment" };
908
+ const names = { shot: "Screenshot", layout: "Check layout", element: "Read element", "pdf-figure": "PDF figure", resolve: "Resolve comment", reply: "Reply to comment" };
908
909
  return m ? { name: names[m[1]] || m[1], detail: m[1] === "pdf-figure" ? m[2].trim().replace(/^\S*\//, "") : m[2].trim() }
909
910
  : { name: "Run", detail: cmd.split("\n")[0].slice(0, 80) };
910
911
  }
@@ -1090,8 +1091,10 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
1090
1091
  ctx.push(...notes.map((n) => "Note: " + n));
1091
1092
  state.notes = [];
1092
1093
  }
1093
- if (selection) ctx.push(`Selected element (what "this" or "it" most likely refers to): ${selection.eid} (${selection.tag}), ` +
1094
- `box x=${selection.box.x} y=${selection.box.y} w=${selection.box.w} h=${selection.box.h} on slide ${selection.slide.index + 1}.`);
1094
+ const selBox = (s) => `${s.eid} (${s.tag}), box x=${s.box.x} y=${s.box.y} w=${s.box.w} h=${s.box.h}`;
1095
+ if (selection?.also?.length) ctx.push(`Selected elements (what "these", "them" or "this" most likely refer to), on slide ${selection.slide.index + 1}:\n` +
1096
+ [selection, ...selection.also].map((s) => "- " + selBox(s)).join("\n"));
1097
+ else if (selection) ctx.push(`Selected element (what "this" or "it" most likely refers to): ${selBox(selection)} on slide ${selection.slide.index + 1}.`);
1095
1098
  if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
1096
1099
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
1097
1100
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide}${screenshot.pins ? " with the pin markers" : ""}: ${screenshot.path} (Read it).`);
@@ -1364,14 +1367,17 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
1364
1367
  /* What deleting an element would remove, and which of its files nothing else uses. Files outside the
1365
1368
  deck folder, in vendor/, or still referenced elsewhere (the rest of the deck, theme.css, brand.css, or
1366
1369
  another deck in the folder or one folder down, which may share figures/) are never offered. */
1367
- function deletePlan(eid) {
1370
+ /* What deleting one or more elements removes: the elements (a picture with its caption; an element inside another
1371
+ one that goes too is left out) and the files they refer to */
1372
+ function deletePlan(eids) {
1368
1373
  const html = readDeck(DECK_FILE);
1369
- const target = deleteTarget(html, eid);
1370
- if (!target) return null;
1371
- const rest = removeElement(html, target.eid);
1374
+ let targets = [].concat(eids).map((eid) => deleteTarget(html, eid));
1375
+ if (!targets.length || targets.some((t) => !t)) return null;
1376
+ targets = targets.filter((t, i) => !targets.some((o, j) => o.eid === t.eid ? j < i : o.outer.includes(`data-eid="${t.eid}"`)));
1377
+ const rest = targets.reduce((h, t) => removeElement(h, t.eid), html);
1372
1378
  const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
1373
1379
  const others = otherDeckRefs();
1374
- const files = fileRefs(target.outer).flatMap((ref) => {
1380
+ const files = [...new Set(targets.flatMap((t) => fileRefs(t.outer)))].flatMap((ref) => {
1375
1381
  const abs = path.resolve(DECK_DIR, ref);
1376
1382
  const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
1377
1383
  if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
@@ -1380,7 +1386,7 @@ function deletePlan(eid) {
1380
1386
  const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel) || usedBy.length > 0;
1381
1387
  return [{ rel, bytes: fs.statSync(abs).size, usedElsewhere, usedBy }];
1382
1388
  });
1383
- return { ...target, html: rest, files };
1389
+ return { ...targets[0], targets: targets.map(({ eid, tag }) => ({ eid, tag })), html: rest, files };
1384
1390
  }
1385
1391
  /* The other decks of this folder and one folder down, each with a test of whether it refers to a file: in its
1386
1392
  HTML (relative to its own folder), or in the stylesheets beside it */
@@ -1648,7 +1654,7 @@ const server = http.createServer(async (req, res) => {
1648
1654
  const ok = authed(req);
1649
1655
  if (req.method === "GET" && (p === "/" || p === "/index.html")) {
1650
1656
  if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
1651
- return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
1657
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("{{VERSION}}", VERSION), req);
1652
1658
  }
1653
1659
  if (req.method === "GET" && p.startsWith("/editor/")) {
1654
1660
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
@@ -1761,6 +1767,7 @@ const server = http.createServer(async (req, res) => {
1761
1767
  const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
1762
1768
  b.pins = (Array.isArray(b.pins) ? b.pins : []).filter((p) => placed(p) && p.point && p.at);
1763
1769
  if (!placed(b.selection)) b.selection = null;
1770
+ else b.selection.also = (Array.isArray(b.selection.also) ? b.selection.also : []).filter((x) => x && typeof x.eid === "string" && x.box);
1764
1771
  if (!Array.isArray(b.layouts)) b.layouts = [];
1765
1772
  if (b.current && !Number.isInteger(b.current.index)) b.current = null;
1766
1773
  const preparation = { cancelled: false, controller: new AbortController() };
@@ -1787,7 +1794,7 @@ const server = http.createServer(async (req, res) => {
1787
1794
  const entry = addEntry({ kind: "user", text: b.text,
1788
1795
  screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
1789
1796
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1790
- selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1797
+ selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, "") + (b.selection.also.length ? ` +${b.selection.also.length}` : ""), slide: b.selection.slide.index + 1 } : null,
1791
1798
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
1792
1799
  if (shotFailed) addEntry({ kind: "notice", text: `Screenshot of slide ${shotFailed.slide} failed: ${shotFailed.error}` });
1793
1800
  startTurn(buildPrompt({ ...b, screenshot, shotFailed }), entry);
@@ -2038,27 +2045,27 @@ const server = http.createServer(async (req, res) => {
2038
2045
 
2039
2046
  // Delete an element (and, if the user agrees, the files only it uses)
2040
2047
  if (p === "/api/delete-info") {
2041
- const plan = deletePlan(url.searchParams.get("eid"));
2048
+ const plan = deletePlan(url.searchParams.getAll("eid"));
2042
2049
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
2043
- return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
2050
+ return json(res, 200, { eid: plan.eid, tag: plan.tag, targets: plan.targets, files: plan.files });
2044
2051
  }
2045
2052
  if (p === "/api/delete" && req.method === "POST") {
2046
2053
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
2047
2054
  const b = await body(req);
2048
2055
  if (staleEdit(res, b)) return;
2049
- const plan = deletePlan(b.eid);
2056
+ const plan = deletePlan(Array.isArray(b.eids) ? b.eids : b.eid);
2050
2057
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
2051
2058
  const before = snapshot(EDITABLE);
2052
2059
  const wanted = new Set(b.files || []);
2053
2060
  const deletedFiles = plan.files.filter((f) => wanted.has(f.rel) && !f.usedElsewhere)
2054
2061
  .map((f) => ({ rel: f.rel, data: fs.readFileSync(path.join(DECK_DIR, f.rel)) }));
2055
- const short = plan.eid.replace(/^[^/]+\//, "/");
2062
+ const short = plan.targets.map((t) => t.eid.replace(/^[^/]+\//, "/")).join(", ");
2056
2063
  const label = `Deleted ${short}` + (deletedFiles.length ? ` and ${deletedFiles.map((f) => f.rel).join(", ")}` : "");
2057
2064
  const turnId = recordHandEdit(before, plan.html, label,
2058
- `The user deleted ${plan.eid} (${plan.tag}) by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
2065
+ `The user deleted ${plan.targets.map((t) => `${t.eid} (${t.tag})`).join(", ")} by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
2059
2066
  { manual: false, deletedFiles });
2060
2067
  for (const f of deletedFiles) removeDeckFile(f.rel);
2061
- return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
2068
+ return json(res, 200, { turnId, deleted: plan.targets.map((t) => t.eid), files: deletedFiles.map((f) => f.rel) });
2062
2069
  }
2063
2070
 
2064
2071
  // Hand edits from the editor: drag/resize (inline styles) and text
@@ -2169,6 +2176,11 @@ const server = http.createServer(async (req, res) => {
2169
2176
  if (why) return text(res, 409, why);
2170
2177
  return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
2171
2178
  }
2179
+ if (p === "/api/element") {
2180
+ const eid = url.searchParams.get("eid") || "";
2181
+ const outer = getOuter(readDeck(DECK_FILE) || "", eid);
2182
+ return outer === null ? text(res, 404, `No element "${eid}" in ${DECK_FILE}. Give a data-eid.`) : text(res, 200, outer);
2183
+ }
2172
2184
  if (p === "/api/layout" || p === "/api/shot") {
2173
2185
  const slide = resolveSlide(url.searchParams.get("slide"));
2174
2186
  if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);