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 +6 -0
- package/README.md +8 -2
- package/cli.mjs +4 -2
- package/docs/how-it-works.md +1 -1
- package/docs/using.md +2 -2
- package/lib/agent-tools.mjs +2 -2
- package/lib/eids.mjs +6 -0
- package/lib/system-prompt.md +4 -2
- package/package.json +1 -1
- package/public/bridge.js +75 -42
- package/public/editor.css +2 -0
- package/public/editor.js +18 -15
- package/public/index.html +1 -1
- package/server.mjs +32 -20
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.
|
|
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.
|
|
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) {
|
package/docs/how-it-works.md
CHANGED
|
@@ -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. |
|
package/lib/agent-tools.mjs
CHANGED
|
@@ -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);
|
package/lib/system-prompt.md
CHANGED
|
@@ -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.
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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(
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
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 || (
|
|
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,
|
|
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 (
|
|
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,
|
|
508
|
-
|
|
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(
|
|
534
|
-
drag.label = `Moved ${
|
|
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.
|
|
606
|
+
if (d.started) commit(d.items, d.label);
|
|
578
607
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
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({
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
235
|
-
nudgeAcc
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 !==
|
|
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}` : "") +
|
|
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", {
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1370
|
-
if (!
|
|
1371
|
-
|
|
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(
|
|
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 { ...
|
|
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"),
|
|
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.
|
|
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} (${
|
|
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\`.`);
|