pindeck 0.0.0-stage → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +315 -2
- package/cli.mjs +97 -0
- package/lib/claude.mjs +70 -0
- package/lib/diff.mjs +46 -0
- package/lib/eids.mjs +608 -0
- package/lib/layout.mjs +37 -0
- package/lib/omml.mjs +175 -0
- package/lib/pdf-render.jxa.js +54 -0
- package/lib/pdf.mjs +68 -0
- package/lib/pptx-import.mjs +682 -0
- package/lib/pptx-render.mjs +100 -0
- package/lib/pptx.mjs +340 -0
- package/lib/system-prompt.md +97 -0
- package/lib/waveform.mjs +67 -0
- package/lib/xml.mjs +55 -0
- package/lib/zip.mjs +46 -0
- package/package.json +38 -4
- package/public/apple-touch-icon.png +0 -0
- package/public/bridge.js +963 -0
- package/public/editor.css +281 -0
- package/public/editor.js +1089 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +8 -0
- package/public/index.html +165 -0
- package/server.mjs +1163 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Emanuël A. P. Habets
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,316 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PinDeck
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Pin it. Say it. Done.** PinDeck is a local editor for HTML slide decks (reveal.js): the slides on
|
|
4
|
+
the left, the slide in the middle, and a chat with Claude Code on the right. You click elements to
|
|
5
|
+
pin them, write what to change, and Claude edits the HTML and CSS. The slide updates in place.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*The example deck in PinDeck. Claude has lined up the three numbers from the first request and
|
|
10
|
+
checked the layout; the next request, still being typed, refers to the new pins 1 and 2.*
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
* **Point instead of describe.** Pins name the exact element, word or table cell; "make 2 match 1"
|
|
15
|
+
is enough. Claude also gets the measured layout of the slide, so it sees overlaps and overflow
|
|
16
|
+
without screenshots.
|
|
17
|
+
* **Edit by hand, too.** Move, resize and nudge elements with snapping, edit text in place (math stays
|
|
18
|
+
TeX), write speaker notes, add, hide, move and delete slides. One undo history covers your edits and
|
|
19
|
+
Claude's.
|
|
20
|
+
* **Stay on brand.** `brand.css` is locked: Claude edits `index.html` and `theme.css` only, and checks
|
|
21
|
+
its own work with PinDeck's layout command.
|
|
22
|
+
* **Bring material in.** Attach figures, audio and PDFs to a message, or import slides from PowerPoint
|
|
23
|
+
into the deck's own layouts.
|
|
24
|
+
* **Get it out.** Present from the browser, or export a handout or presentation PDF.
|
|
25
|
+
* **Local and small.** One Node process on 127.0.0.1, no npm dependencies, your normal Claude login.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx pindeck path/to/deck # from npm
|
|
31
|
+
|
|
32
|
+
git clone https://github.com/ehabets/PinDeck.git && cd PinDeck
|
|
33
|
+
node server.mjs ocean-example # or from source, with the example deck in this repository
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then open <http://127.0.0.1:5180/>. Requirements: Node 20 or later and the `claude` CLI, signed in
|
|
37
|
+
with your normal Claude account. Chrome (or Chromium, Edge, Brave) is optional and is used for
|
|
38
|
+
screenshots and PDF export; PowerPoint or Keynote (macOS) for pictures in the PowerPoint import.
|
|
39
|
+
|
|
40
|
+
Options: `--port 5180`, `--model <name>` (for this run only; otherwise the model in
|
|
41
|
+
`~/.pindeck/config.json`), `--help`. The deck folder defaults to the current folder and must contain
|
|
42
|
+
`index.html`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck/` (change with
|
|
43
|
+
`PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
|
|
44
|
+
|
|
45
|
+
## Preparing a deck
|
|
46
|
+
|
|
47
|
+
PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`. Two
|
|
48
|
+
optional `<meta>` tags in the deck's `<head>` tell it about the design:
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<meta name="pindeck:footer-top" content="652"> <!-- y of the footer rule: content must stay above it -->
|
|
52
|
+
<meta name="pindeck:locked" content=".slide-foot, .logo"> <!-- brand elements that cannot be selected or moved -->
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Hidden slides use reveal.js's `data-visibility="hidden"`. If the deck numbers its slides itself (as
|
|
56
|
+
the example's `deck.js` does for the footer), count only slides without that attribute, or the
|
|
57
|
+
numbers will jump where a slide is hidden.
|
|
58
|
+
|
|
59
|
+
Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
|
|
60
|
+
`brand.css` in the deck folder is locked for Claude. See `ocean-example/` for a complete deck.
|
|
61
|
+
|
|
62
|
+
## Making your own template
|
|
63
|
+
|
|
64
|
+
PinDeck has no template generator; a template is simply a deck folder. Start from a copy of the example:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cp -R ocean-example my-template
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
1. **Brand in `brand.css`:** palette, backgrounds, footer and logo positions. PinDeck locks this file
|
|
71
|
+
for Claude, so edit it by hand (or with Claude Code outside PinDeck). Replace the logos in
|
|
72
|
+
`assets/` too.
|
|
73
|
+
2. **Everything else in `theme.css`:** type sizes, spacing, layouts and components. This can be done
|
|
74
|
+
in PinDeck with Claude.
|
|
75
|
+
3. **`index.html`:** the footer text in the `data-` attributes of `<div class="reveal">`, and the
|
|
76
|
+
`pindeck:footer-top` and `pindeck:locked` meta tags if the footer or the brand elements move.
|
|
77
|
+
`deck.js` holds the math macros, the footer markup and the audio player.
|
|
78
|
+
4. **`README.md` and `CLAUDE.md`:** Claude follows both, so describe the new classes and rules there.
|
|
79
|
+
The example's `CLAUDE.md` calls `../cli.mjs` and `../server.mjs`; fix these paths if the template
|
|
80
|
+
does not sit next to PinDeck.
|
|
81
|
+
|
|
82
|
+
**New slide** and the PowerPoint import model new slides on the deck's own: keep a slide with class
|
|
83
|
+
`title-slide`, one with `section-slide`, and a content slide with `.slide-head` (`h2`, optionally a
|
|
84
|
+
`.kicker` or `.sub`) and `.slide-body`. Imported tables take the class of the deck's first table,
|
|
85
|
+
and imported audio copies the first `.demo-row` (with a `data-target`). Without a title or section
|
|
86
|
+
slide, those PowerPoint slides are imported as content slides; without a `.demo-row`, audio gets
|
|
87
|
+
plain players.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
`~/.pindeck/config.json` (or `$PINDECK_HOME/config.json`) is created on first start:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"model": "sonnet",
|
|
96
|
+
"effort": "medium"
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`model` is `opus` or `sonnet`; `effort` is `low`, `medium`, `high`, `xhigh` or `max`. A choice in the
|
|
101
|
+
chat header's menu is written to this file, so it always shows what PinDeck uses. Edit it while
|
|
102
|
+
PinDeck is stopped, or simply use the menu.
|
|
103
|
+
|
|
104
|
+
## Keeping the ids out of git
|
|
105
|
+
|
|
106
|
+
PinDeck adds `data-eid` attributes to the deck it opens. If the deck is in git, run once per clone:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
node cli.mjs git-setup path/to/deck
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This adds a line like `path/to/deck/*.html filter=pindeck-ids` to `.gitattributes` (commit it) and
|
|
113
|
+
defines the filter in the clone's git config: git then strips the ids whenever it reads the deck's
|
|
114
|
+
HTML. The ids stay in the file on disk, where PinDeck and Claude need them, but commits and diffs
|
|
115
|
+
never contain them, and a change that only adds ids does not show in `git status`. Your own edits
|
|
116
|
+
show and commit as usual. Clones without the filter configured simply see the file as it is.
|
|
117
|
+
|
|
118
|
+
## Working with it
|
|
119
|
+
|
|
120
|
+
| Action | How |
|
|
121
|
+
|---|---|
|
|
122
|
+
| 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. |
|
|
123
|
+
| 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. |
|
|
124
|
+
| Undo, redo | `⌘Z` / `⇧⌘Z` or the toolbar arrows. One history for your hand edits and Claude's changes. |
|
|
125
|
+
| Pin elements | Press `P` (or the Pin button), click elements; each click gets a number. Click a marker to remove it, `Esc` to stop. |
|
|
126
|
+
| Ask Claude | 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. |
|
|
127
|
+
| Visual questions | **+** in the chat, then **Screenshot**: the current slide with its pin markers is attached when you send. |
|
|
128
|
+
| Review | Each answer ends with the changed files, a **Diff** button and an **Undo** button (most recent change). |
|
|
129
|
+
| Stop | **Stop** interrupts Claude; the conversation continues. **New chat** starts over. |
|
|
130
|
+
| New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides, and its title opens for typing. |
|
|
131
|
+
| Import from PowerPoint | **+ New slide** → **Import from PowerPoint…**, then choose a `.pptx` file and tick the slides you want (hidden PowerPoint slides start unticked). They are added after the current slide in this deck's own layouts, and Claude can improve the complex ones; see [PowerPoint import](#powerpoint-import). `⌘Z` removes them again, with their figures. |
|
|
132
|
+
| Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes with formatting other than line breaks are edited as HTML. Drag the line above the notes to give them more or less room (double-click it for the default size); collapse them with their header. |
|
|
133
|
+
| Move a slide | Drag it up or down in the slide list; an orange line shows where it will go. It moves with its notes and the comment above it, and `⌘Z` puts it back. Slides inside vertical stacks cannot be moved yet. |
|
|
134
|
+
| Hide a slide | The eye on a slide in the list, or `H`. A hidden slide stays in the deck and in PinDeck (greyed out, with a banner) but is skipped when presenting and in PDFs; **Export ▾** can include hidden slides. Show it the same way. |
|
|
135
|
+
| Model and effort | Click **Claude ▾** in the chat header: Opus or Sonnet, and the effort (how long Claude thinks: low to max). The conversation continues after a change. The choice is saved in `~/.pindeck/config.json` (default: Sonnet, medium). |
|
|
136
|
+
| Attach figures and audio | **+** in the chat, then **File…**, drag and drop onto the chat or a slide, or paste an image. Files are saved in `<deck>/figures/` straight away; audio also gets a waveform SVG. Then ask Claude, e.g. "put this figure on a new slide after 4". Removing an attachment before sending deletes the file again. |
|
|
137
|
+
| Attach a PDF | The same way: a figure or table to take over, or a paper or report with more details. PDFs stay outside the deck (in `~/.pindeck`); Claude reads them, rebuilds tables as HTML, and renders a figure (a page or a cropped part of one) into `<deck>/figures/` as PNG with `cli.mjs pdf-figure` (macOS). E.g. "put Fig. 3 of this paper on slide 7" or "add the results of Table 2 to slide 9". |
|
|
138
|
+
| Export PDF | **Export ▾** in the toolbar. **Handout PDF**: one page per slide, build steps shown complete. **Presentation PDF**: one page per click, so a slide with two build steps gives three pages. Files go to `<deck>/export/`. Needs Chrome or Chromium. |
|
|
139
|
+
| Navigate | Slide list, `←` `→`, or the toolbar. **Present** opens the deck in a new tab. |
|
|
140
|
+
|
|
141
|
+
You can also edit `index.html` or `theme.css` in any editor; the slide reloads, and Claude is told
|
|
142
|
+
the files changed. CSS changes are swapped in without a reload.
|
|
143
|
+
|
|
144
|
+
## PowerPoint import
|
|
145
|
+
|
|
146
|
+
The import keeps what can be carried over exactly and makes pictures of the rest:
|
|
147
|
+
|
|
148
|
+
* **Text becomes HTML** in the deck's layouts: the title (and the subtitle, as a `.sub` line or a
|
|
149
|
+
kicker, as the deck does it), bullets with their levels, numbered lists, bold, italics, coloured words (as `.hl`),
|
|
150
|
+
superscripts, web and mail links and line breaks, other text as paragraphs, references at the bottom of a slide
|
|
151
|
+
as `.refs`, and the speaker notes. Side notes and labels clearly smaller than the body text stay smaller.
|
|
152
|
+
* **Tables become HTML tables** with the class of the deck's own tables, merged header cells, header rows
|
|
153
|
+
and a caption from the text above them. Cells merged down in the body become a label on the group's
|
|
154
|
+
first row with empty cells below it, and each later group's first row gets `class="group"`. A table with shapes drawn over it (e.g. boxes marking rows)
|
|
155
|
+
is kept as a picture instead.
|
|
156
|
+
* **Build steps:** shapes and paragraphs that appear (or disappear) on a click become reveal.js
|
|
157
|
+
fragments in the same order, also when several things appear on one click. A picture made of several
|
|
158
|
+
shapes appears on a click only if all of its shapes do.
|
|
159
|
+
* **Audio** goes into the deck's own audio player (a copy of its first `.demo-row`, with a waveform made
|
|
160
|
+
from the sound), labelled with the nearest text on the slide; without such a player, plain players.
|
|
161
|
+
**Video** keeps its place, with its poster frame.
|
|
162
|
+
The first slide is filled into the deck's title slide (title, subtitle, authors, event, institute)
|
|
163
|
+
and PowerPoint section headers into its section divider.
|
|
164
|
+
* **Pictures are copied** to `figures/` (`<file>-s04-1.png`, ...).
|
|
165
|
+
* **Everything drawn** (diagrams built from shapes and arrows, charts, pictures with labels on
|
|
166
|
+
them, EMF graphics) becomes a picture cut from the slide as PowerPoint renders it. For this PinDeck
|
|
167
|
+
asks PowerPoint (or else Keynote) to save the presentation as a PDF; macOS asks once whether PinDeck
|
|
168
|
+
may control the app. The same PDF gives the thumbnails in the import dialog.
|
|
169
|
+
* **Layout:** blocks that sit side by side become `.cols` with the slide's proportions, others are
|
|
170
|
+
stacked top to bottom; figures and, if needed, the text are scaled so that the slide fits.
|
|
171
|
+
* **Equations** (PowerPoint's equation editor) become TeX for the deck's KaTeX: `\( \)` in the text,
|
|
172
|
+
`\[ \]` on their own line; fractions, scripts, roots, sums and integrals, functions and limits,
|
|
173
|
+
accents, braces, matrices and the bold, italic and double-struck math letters are converted.
|
|
174
|
+
* **Hidden slides** stay hidden (`data-visibility="hidden"`).
|
|
175
|
+
|
|
176
|
+
**Hand-off to Claude.** Slides the import can only approximate are tagged *complex* in the dialog:
|
|
177
|
+
diagrams of many shapes or tables with shapes over them that became pictures, pictures placed inside
|
|
178
|
+
text (often equations as images), build steps on parts of a diagram, slides without a title, and
|
|
179
|
+
text that had to shrink to fit. With **Then ask Claude to improve the complex slides** ticked, Claude
|
|
180
|
+
gets these slides right after the import, each with a picture of the original slide and the texts of
|
|
181
|
+
its diagrams, and rebuilds diagrams as SVG, equations as TeX and tables as HTML in the deck's style.
|
|
182
|
+
The request shows in the chat like your own messages, and its changes can be undone as usual.
|
|
183
|
+
|
|
184
|
+
Without PowerPoint or Keynote (or on other systems than macOS) only the text and the pictures are imported.
|
|
185
|
+
|
|
186
|
+
## How it works
|
|
187
|
+
|
|
188
|
+
* **Element ids.** On start, the server gives every slide and every visible element a readable
|
|
189
|
+
`data-eid` attribute (`summary`, `summary/callout`, `summary/li-2`). Only the attribute is
|
|
190
|
+
inserted; the rest of the file stays as it was. A backup of the file before the first change is
|
|
191
|
+
kept in `~/.pindeck/decks/<deck>/backups/`. Ids survive edits, so pins, layout reports and Claude all name
|
|
192
|
+
the same element.
|
|
193
|
+
* **Hand edits.** A move is stored as the CSS `translate` property and a resize as `width`/`height`,
|
|
194
|
+
inline on the element in `index.html`. `translate` moves an element without changing the layout
|
|
195
|
+
around it, for elements in the flow and absolutely positioned ones alike. Only that one style
|
|
196
|
+
attribute is rewritten; the rest of the file stays as it was. Claude gets a note about each hand
|
|
197
|
+
edit. Brand-locked elements (footer, logos) cannot be selected.
|
|
198
|
+
* **Text edits.** While you type, the element holds its source HTML from `index.html`, not the
|
|
199
|
+
rendered slide, so math stays TeX and nothing that KaTeX or the deck script generates ends up in
|
|
200
|
+
the file. On save only that element's content is replaced. Browser editing artefacts are cleaned
|
|
201
|
+
up: `<b>`/`<i>` become `<strong>`/`<em>`, styled spans from formatting are removed, trailing
|
|
202
|
+
`<br>`s dropped.
|
|
203
|
+
* **Pins.** A pin records the element id, its box in slide coordinates (1280 x 720), the click
|
|
204
|
+
point, the clicked word and, in tables, the cell. Pins on the footer or logos are marked as
|
|
205
|
+
locked.
|
|
206
|
+
* **Layout instead of screenshots.** With each message the editor measures the current slide and
|
|
207
|
+
every pinned slide in your browser: position and size of every element, font sizes, and issues
|
|
208
|
+
(content below the footer rule, outside the slide, overlaps, clipped text). Claude gets this as
|
|
209
|
+
text. To check its own work it runs `node ../cli.mjs layout <slide>...` (`--issues` for the
|
|
210
|
+
issues only). Screenshots are
|
|
211
|
+
only taken when you tick the box or Claude needs a visual judgement.
|
|
212
|
+
* **One Claude Code process.** The server starts `claude -p` in stream-json mode once and keeps it
|
|
213
|
+
running, so there is no start-up delay per message. It runs in the deck folder under your Claude
|
|
214
|
+
login, with `--append-system-prompt` from `lib/system-prompt.md` and the deck's `CLAUDE.md`.
|
|
215
|
+
* **Permissions.** Claude may read files, edit `index.html` and `theme.css`, and run the editor's
|
|
216
|
+
CLI. Nothing else, and in particular no edits to `brand.css`. Claude Code loads no settings files
|
|
217
|
+
and no MCP servers, so no allow rule in your own, the deck's or a parent folder's settings widens this. If `brand.css` changes during a
|
|
218
|
+
turn anyway, the server restores it and says so in the chat.
|
|
219
|
+
* **Undo.** The server keeps a copy of the editable files from before each change (a Claude turn
|
|
220
|
+
or a hand edit). Undo and redo walk that history and tell Claude on the next message.
|
|
221
|
+
|
|
222
|
+
## Files
|
|
223
|
+
|
|
224
|
+
| File | Purpose |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `server.mjs` | Local server (127.0.0.1 only, random token per run): UI, deck files, events, Claude session, undo, CLI endpoints. |
|
|
227
|
+
| `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for Claude (and for you), and `git-setup`. |
|
|
228
|
+
| `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
|
|
229
|
+
| `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
|
|
230
|
+
| `lib/pptx-import.mjs` | Turns PowerPoint slides into slides of the deck: figures, tables, build steps, audio, layout, HTML in the deck's style. |
|
|
231
|
+
| `lib/waveform.mjs` | Waveform SVGs for imported audio. |
|
|
232
|
+
| `lib/omml.mjs` | PowerPoint equations (Office Math) to TeX. |
|
|
233
|
+
| `lib/pptx-render.mjs`, `lib/pdf-render.jxa.js` | Slide pictures: PowerPoint or Keynote saves a PDF, macOS PDFKit renders pages and regions of it. |
|
|
234
|
+
| `lib/pdf.mjs` | PDF export: reveal.js print layout in headless Chrome, then print to PDF. |
|
|
235
|
+
| `lib/eids.mjs` | Adds the `data-eid` attributes by editing the HTML as text. |
|
|
236
|
+
| `lib/claude.mjs` | The long-running Claude Code process. |
|
|
237
|
+
| `lib/system-prompt.md` | What Claude is told about the editor. |
|
|
238
|
+
| `CONTRIBUTING.md` | How to report bugs and contribute. |
|
|
239
|
+
| `docs/` | Images for this README. |
|
|
240
|
+
| `LICENSE` | MIT. |
|
|
241
|
+
|
|
242
|
+
## Current Limitations
|
|
243
|
+
|
|
244
|
+
* Text inside inline SVG diagrams cannot be edited in place; pin it and ask Claude.
|
|
245
|
+
* Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
|
|
246
|
+
* No indenting or outdenting of bullets with `Tab` yet, and `Backspace` at the start of a bullet does not merge it with the one above; ask Claude for those.
|
|
247
|
+
* Moves are offsets (`translate`), so the space an element came from stays reserved. Ask Claude to turn hand edits into proper CSS when a layout should really change.
|
|
248
|
+
* The undo history lives on the server and starts fresh when PinDeck restarts.
|
|
249
|
+
|
|
250
|
+
## Versions
|
|
251
|
+
|
|
252
|
+
* **2.8**: a code review: safer, sturdier, and a PowerPoint import that keeps more. The server answers
|
|
253
|
+
only to `127.0.0.1` and `localhost`, so another website cannot read the editor's token (DNS
|
|
254
|
+
rebinding). A missing `claude` command ends the turn with a message instead of leaving the chat
|
|
255
|
+
working forever, and a replaced Claude Code process can no longer end the next turn. Esc in the import
|
|
256
|
+
dialog no longer repeats the last import, and an import never overwrites edits made while it runs. The
|
|
257
|
+
PowerPoint import keeps animations that play by themselves off the clicks, wraps shapes that come and
|
|
258
|
+
go in fragments of their own, skips hidden shapes and `javascript:` links, reads packages with
|
|
259
|
+
absolute part names, takes only the first line of a subtitle placeholder as the subtitle, keeps
|
|
260
|
+
equations in titles and notes, writes numbered lists as `<ol>`, keeps the text of coloured boxes
|
|
261
|
+
without PowerPoint or Keynote, and makes valid TeX of escaped text, equation arrays, odd delimiters
|
|
262
|
+
and primes after superscripts. Damaged WAV or XML parts no longer stop an import. The toolbar wraps
|
|
263
|
+
instead of overlapping when the slide area is narrow.
|
|
264
|
+
* **2.7**: attach PDFs to chat messages (a figure, a table, a paper or report). They stay outside the deck; Claude reads
|
|
265
|
+
them, rebuilds tables as HTML and renders figures into `figures/` with `cli.mjs pdf-figure` (a page or a cropped part, macOS).
|
|
266
|
+
* **2.6.1**: tinted table rows are one rectangle per row instead of one per cell, so PDF viewers show no hairline seams between the cells.
|
|
267
|
+
* **2.6**: booktabs-style tables in both example decks (rules at top, under the header and at the bottom,
|
|
268
|
+
short rules under grouped headings, numbers right-aligned); the import writes row groups without `rowspan`.
|
|
269
|
+
* **2.5**: a notes field under the slide for the speaker notes, saved as hand edits with undo.
|
|
270
|
+
* **2.4.1**: the layout check judges a plain container by what it draws (its children), not by its box, so
|
|
271
|
+
a body stretched down to the references no longer counts as an overlap; a real overlap is reported once,
|
|
272
|
+
for the outermost pair.
|
|
273
|
+
* **2.4**: move slides by dragging them in the slide list; undo puts them back.
|
|
274
|
+
* **2.3**: lessons from a PowerPoint hand-off. Claude Code runs without settings files or MCP servers
|
|
275
|
+
(the deck's `CLAUDE.md` goes into the system prompt instead), so stray allow rules no longer let it run
|
|
276
|
+
scripts; `layout` takes several slides and `--issues`; the chat no longer labels a refused script as a
|
|
277
|
+
layout check; shared SVG arrowheads live outside the slides, where hidden slides cannot hide them.
|
|
278
|
+
* **2.2**: PowerPoint import with equations as TeX for KaTeX, and the hand-off to Claude: complex slides are
|
|
279
|
+
tagged in the import dialog and, if wanted, improved by Claude right after the import, with a picture of
|
|
280
|
+
each original slide.
|
|
281
|
+
* **2.1**: PowerPoint import with build steps (fragments in click order), HTML tables in the deck's table
|
|
282
|
+
style, audio in the deck's player with a waveform, video with its poster, coloured words as `.hl`.
|
|
283
|
+
* **2.0**: import slides from PowerPoint (`.pptx`): text as HTML in the deck's layouts, pictures copied,
|
|
284
|
+
diagrams, tables and charts as pictures cut from PowerPoint's rendering; thumbnails in the import
|
|
285
|
+
dialog; undo removes the imported slides and their figures.
|
|
286
|
+
* **1.12.1**: the PinDeck pin as the browser tab icon (SVG, with PNG versions for Safari).
|
|
287
|
+
* **1.12**: double-clicking an equation opens its text with the equation's TeX selected, also for
|
|
288
|
+
equations on their own line and for parts KaTeX draws as SVG (roots, wide accents, arrows).
|
|
289
|
+
* **1.11.3**: a calmer interface: no slide count, no Hide/Show button in the header (eye and `H`
|
|
290
|
+
remain), no messages or chat entries for hiding and showing, shorter export descriptions.
|
|
291
|
+
* **1.11.2**: the header shows the deck's name instead of the slide title; the browser tab is named after the deck.
|
|
292
|
+
* **1.11.1**: the Hide and Show icons sit at the same height.
|
|
293
|
+
* **1.11**: hide and show slides (eye in the slide list, toolbar button, `H`); hidden slides are skipped
|
|
294
|
+
when presenting and in PDFs unless the export includes them.
|
|
295
|
+
* **1.10**: model and effort without "Default" entries (default Sonnet, medium), set in `~/.pindeck/config.json`.
|
|
296
|
+
* **1.9**: `node cli.mjs git-setup` keeps PinDeck's ids out of git with a clean filter; id-only changes no longer show in `git status`.
|
|
297
|
+
* **1.8**: + New slide (content slide, section divider, duplicate); model (Opus, Sonnet) and effort menu in the chat header; a + menu in the chat for files and screenshots.
|
|
298
|
+
* **1.7**: `Delete` removes the selected element after a confirmation, a picture with its caption, and optionally the files nothing else uses; undo restores elements and files. Claude reads attached images directly instead of trying to convert them; refused commands are explained in the chat.
|
|
299
|
+
* **1.6**: attach figures and audio to chat messages (file picker, drag and drop, paste); audio gets a waveform SVG; files dropped on a slide carry their position.
|
|
300
|
+
* **1.5**: PDF export (handout and presentation with build steps on separate pages).
|
|
301
|
+
* **1.4**: decks describe their footer and locked brand elements with `<meta name="pindeck:...">` tags; the layout check ignores invisible elements. New public example deck: `ocean-example/`.
|
|
302
|
+
* **1.3**: `Enter` in a bullet starts a new bullet (split at the cursor); empty new bullets are removed again.
|
|
303
|
+
* **1.2**: text editing on the slide (double-click or Enter), with math as TeX, table cells, bold and italic, plain-text paste; text edits are part of the undo history and reported to Claude.
|
|
304
|
+
* **1.1**: select, drag, resize and nudge with snapping guides; one undo/redo history for hand edits and Claude's changes; the selected element is sent to Claude.
|
|
305
|
+
* **1.0**: pins, chat with Claude Code, layout reports, element ids, diff and undo.
|
|
306
|
+
|
|
307
|
+
## Contributing
|
|
308
|
+
|
|
309
|
+
Bug reports, ideas, example decks and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
310
|
+
explains how to report a bug, set up PinDeck for development, test a change by hand, and what a pull
|
|
311
|
+
request should contain. In short: no npm dependencies, deck files are edited only where they must
|
|
312
|
+
change, Claude's permissions stay narrow, and the README describes what users see.
|
|
313
|
+
|
|
314
|
+
## License
|
|
315
|
+
|
|
316
|
+
MIT, see [LICENSE](LICENSE).
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* PinDeck command line: Claude's self-checks (need the PinDeck server running) and git setup.
|
|
3
|
+
|
|
4
|
+
node cli.mjs layout <slide>... measured layout and issues of one or more slides
|
|
5
|
+
(add --issues for the issues only: layout 9 10 11 --issues)
|
|
6
|
+
node cli.mjs shot <slide>... screenshots with pin markers; prints the PNG paths
|
|
7
|
+
node cli.mjs slides slide numbers and ids
|
|
8
|
+
node cli.mjs pdf handout PDF with one page per slide (build steps in their final state)
|
|
9
|
+
node cli.mjs pdf presentation PDF with one page per build step
|
|
10
|
+
(add "all" to include hidden slides: pdf handout all)
|
|
11
|
+
node cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width 1600]
|
|
12
|
+
a page of an attached PDF, or a box of it (fractions of the page
|
|
13
|
+
from the top left), as PNG: without a name in the screenshots
|
|
14
|
+
folder to look at, with one saved into the deck
|
|
15
|
+
|
|
16
|
+
node cli.mjs git-setup [deck] keep PinDeck's data-eid ids out of git (no server needed)
|
|
17
|
+
|
|
18
|
+
<slide> is a slide id (data-eid), a slide number (1-based) or "current". */
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
26
|
+
const arg = rest[0];
|
|
27
|
+
|
|
28
|
+
/* git-setup: a git "clean" filter strips PinDeck's data-eid attributes whenever git reads the deck's
|
|
29
|
+
HTML, so ids never reach a commit and id-only changes do not show in `git status`. The ids stay in
|
|
30
|
+
the file on disk, where PinDeck and Claude need them. The filter is plain sed, so it does not depend
|
|
31
|
+
on where PinDeck is installed; git config is per clone, so run this once in each clone. */
|
|
32
|
+
if (cmd === "git-setup") {
|
|
33
|
+
const FILTER = `sed 's/ data-eid="[^"]*"//g'`;
|
|
34
|
+
const deck = path.resolve(arg || ".");
|
|
35
|
+
const git = (...a) => execFileSync("git", ["-C", deck, ...a], { encoding: "utf8" }).trim();
|
|
36
|
+
let root;
|
|
37
|
+
try { root = git("rev-parse", "--show-toplevel"); }
|
|
38
|
+
catch { console.error(`${deck} is not inside a git repository.`); process.exit(1); }
|
|
39
|
+
git("config", "filter.pindeck-ids.clean", FILTER);
|
|
40
|
+
const rel = path.relative(root, deck).split(path.sep).join("/");
|
|
41
|
+
const pattern = `${rel ? rel + "/" : ""}*.html`;
|
|
42
|
+
const attrFile = path.join(root, ".gitattributes");
|
|
43
|
+
const attrs = fs.existsSync(attrFile) ? fs.readFileSync(attrFile, "utf8") : "";
|
|
44
|
+
const line = `${pattern} filter=pindeck-ids`;
|
|
45
|
+
const added = !attrs.split("\n").some((l) => l.trim() === line);
|
|
46
|
+
if (added) fs.writeFileSync(attrFile, attrs + (attrs && !attrs.endsWith("\n") ? "\n" : "") + line + "\n");
|
|
47
|
+
console.log(`Git filter "pindeck-ids" configured in ${root} (local git config).`);
|
|
48
|
+
console.log(added ? `Added "${line}" to .gitattributes; commit that file.` : `.gitattributes already has "${line}".`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
const issuesOnly = cmd === "layout" && rest.includes("--issues");
|
|
52
|
+
const slides = rest.filter((a) => a !== "--issues");
|
|
53
|
+
if (!["layout", "shot", "slides", "pdf", "pdf-figure"].includes(cmd)) {
|
|
54
|
+
console.error("Usage: node cli.mjs layout|shot <slide>... [--issues] | node cli.mjs slides | node cli.mjs pdf handout|presentation | " +
|
|
55
|
+
"node cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px] | node cli.mjs git-setup [deck]");
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
/* pdf-figure: the box and the output name are told apart by their form, so their order does not matter */
|
|
59
|
+
function figureQuery(args) {
|
|
60
|
+
const w = args.indexOf("--width"), width = w >= 0 ? args.splice(w, 2)[1] : "";
|
|
61
|
+
const [file = "", page = "1", ...more] = args;
|
|
62
|
+
return { file: file && path.resolve(file), page, width,
|
|
63
|
+
box: more.find((a) => /^[\d.]+(,[\d.]+){3}$/.test(a)) || "", out: more.find((a) => /\.png$/i.test(a)) || "" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let url = process.env.DECK_EDITOR_URL, token = process.env.DECK_EDITOR_TOKEN;
|
|
67
|
+
if (!url || !token) {
|
|
68
|
+
try {
|
|
69
|
+
const home = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
|
|
70
|
+
const s = JSON.parse(fs.readFileSync(path.join(home, "server.json"), "utf8"));
|
|
71
|
+
url = s.url; token = s.token;
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
if (!url) { console.error("The PinDeck server is not running (start it with: pindeck <deck-folder>)."); process.exit(1); }
|
|
75
|
+
|
|
76
|
+
/* layout and shot take several slides, so Claude needs no shell loop or pipe (which PinDeck refuses) */
|
|
77
|
+
const queries = cmd === "pdf-figure" ? [figureQuery([...rest])]
|
|
78
|
+
: cmd === "pdf" ? [{ slide: rest[0] || "handout", ...(rest[1] === "all" ? { all: "1" } : {}) }]
|
|
79
|
+
: cmd === "slides" ? [{}] : (slides.length ? slides : ["current"]).map((slide) => ({ slide }));
|
|
80
|
+
let ok = true;
|
|
81
|
+
for (const q of queries) {
|
|
82
|
+
let res, out;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetch(`${url}/api/${cmd}?` + new URLSearchParams(q), { headers: { "x-editor-token": token } });
|
|
85
|
+
out = await res.text();
|
|
86
|
+
} catch {
|
|
87
|
+
console.error("Cannot reach the PinDeck server at " + url + ". Is it running?");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (res.ok && issuesOnly) {
|
|
91
|
+
const lines = out.split("\n"), i = lines.findIndex((l) => l.startsWith("Issues"));
|
|
92
|
+
out = [lines[0], ...lines.slice(i)].join("\n");
|
|
93
|
+
}
|
|
94
|
+
(res.ok ? console.log : console.error)(out);
|
|
95
|
+
ok &&= res.ok;
|
|
96
|
+
}
|
|
97
|
+
process.exit(ok ? 0 : 1);
|
package/lib/claude.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* A long-running Claude Code process in stream-json mode.
|
|
2
|
+
|
|
3
|
+
One process serves the whole chat: messages are written to stdin as JSON lines,
|
|
4
|
+
events come back on stdout. It runs under the user's normal Claude login. */
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import readline from "node:readline";
|
|
8
|
+
|
|
9
|
+
export class ClaudeSession {
|
|
10
|
+
constructor({ cwd, args, env, onEvent, onExit, onStderr }) {
|
|
11
|
+
Object.assign(this, { cwd, args, env, onEvent, onExit, onStderr });
|
|
12
|
+
this.proc = null;
|
|
13
|
+
this.sessionId = null;
|
|
14
|
+
this.reqId = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get running() { return !!this.proc; }
|
|
18
|
+
|
|
19
|
+
start(resumeId) {
|
|
20
|
+
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
|
|
21
|
+
...(typeof this.args === "function" ? this.args() : this.args), ...(resumeId ? ["--resume", resumeId] : [])];
|
|
22
|
+
const proc = spawn(process.env.CLAUDE_BIN || "claude", args, {
|
|
23
|
+
cwd: this.cwd, env: { ...process.env, ...this.env }, stdio: ["pipe", "pipe", "pipe"],
|
|
24
|
+
});
|
|
25
|
+
this.proc = proc;
|
|
26
|
+
readline.createInterface({ input: proc.stdout }).on("line", (line) => {
|
|
27
|
+
if (this.proc !== proc && this.proc) return; // a stopped process that is still talking
|
|
28
|
+
if (!line.trim()) return;
|
|
29
|
+
let ev;
|
|
30
|
+
try { ev = JSON.parse(line); } catch { return; }
|
|
31
|
+
if (ev.session_id) this.sessionId = ev.session_id;
|
|
32
|
+
this.onEvent(ev);
|
|
33
|
+
});
|
|
34
|
+
proc.stderr.on("data", (d) => this.onStderr?.(String(d)));
|
|
35
|
+
proc.stdin.on("error", () => {}); // writing to a process that has died: its exit is reported below
|
|
36
|
+
// a process that could not start (no `claude` on the PATH) reports an error and never exits
|
|
37
|
+
let ended = false;
|
|
38
|
+
const end = (code, signal) => {
|
|
39
|
+
if (ended) return;
|
|
40
|
+
ended = true;
|
|
41
|
+
// stale: a newer process has been started since (model change, new chat); its turn is not ours to end
|
|
42
|
+
const stale = this.proc !== proc && this.proc !== null;
|
|
43
|
+
if (this.proc === proc) this.proc = null;
|
|
44
|
+
this.onExit(code, signal, { stale });
|
|
45
|
+
};
|
|
46
|
+
proc.on("error", (err) => { this.onStderr?.(String(err)); if (proc.pid === undefined) end(null, null); });
|
|
47
|
+
proc.on("exit", end);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
send(content) {
|
|
51
|
+
this.#write({ type: "user", message: { role: "user", content } });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interrupt() {
|
|
55
|
+
this.#write({ type: "control_request", request_id: `int-${++this.reqId}`, request: { subtype: "interrupt" } });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop() {
|
|
59
|
+
if (!this.proc) return;
|
|
60
|
+
const p = this.proc;
|
|
61
|
+
this.proc = null;
|
|
62
|
+
p.stdin.end();
|
|
63
|
+
setTimeout(() => p.kill("SIGTERM"), 1500);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#write(obj) {
|
|
67
|
+
if (!this.proc) throw new Error("Claude Code is not running");
|
|
68
|
+
this.proc.stdin.write(JSON.stringify(obj) + "\n");
|
|
69
|
+
}
|
|
70
|
+
}
|
package/lib/diff.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* Line diff (LCS) with unified-style hunks, good enough for slide-sized files. */
|
|
2
|
+
|
|
3
|
+
export function diffLines(a, b) {
|
|
4
|
+
const A = a.split("\n"), B = b.split("\n");
|
|
5
|
+
// trim common prefix/suffix so the LCS table stays small
|
|
6
|
+
let pre = 0;
|
|
7
|
+
while (pre < A.length && pre < B.length && A[pre] === B[pre]) pre++;
|
|
8
|
+
let suf = 0;
|
|
9
|
+
while (suf < A.length - pre && suf < B.length - pre && A[A.length - 1 - suf] === B[B.length - 1 - suf]) suf++;
|
|
10
|
+
const a2 = A.slice(pre, A.length - suf), b2 = B.slice(pre, B.length - suf);
|
|
11
|
+
const n = a2.length, m = b2.length;
|
|
12
|
+
const L = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
13
|
+
for (let i = n - 1; i >= 0; i--)
|
|
14
|
+
for (let j = m - 1; j >= 0; j--)
|
|
15
|
+
L[i][j] = a2[i] === b2[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
16
|
+
const ops = [];
|
|
17
|
+
for (let k = 0; k < pre; k++) ops.push([" ", A[k], k + 1, k + 1]);
|
|
18
|
+
let i = 0, j = 0;
|
|
19
|
+
while (i < n || j < m) {
|
|
20
|
+
if (i < n && j < m && a2[i] === b2[j]) { ops.push([" ", a2[i], pre + i + 1, pre + j + 1]); i++; j++; }
|
|
21
|
+
else if (i < n && (j === m || L[i + 1][j] >= L[i][j + 1])) { ops.push(["-", a2[i], pre + i + 1, null]); i++; }
|
|
22
|
+
else { ops.push(["+", b2[j], null, pre + j + 1]); j++; }
|
|
23
|
+
}
|
|
24
|
+
for (let k = 0; k < suf; k++) ops.push([" ", A[A.length - suf + k], A.length - suf + k + 1, B.length - suf + k + 1]);
|
|
25
|
+
return ops;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function unifiedDiff(a, b, file, context = 2) {
|
|
29
|
+
const ops = diffLines(a, b);
|
|
30
|
+
let added = 0, removed = 0;
|
|
31
|
+
const changed = ops.map((o, k) => (o[0] !== " " ? k : -1)).filter((k) => k >= 0);
|
|
32
|
+
if (!changed.length) return { text: "", added, removed };
|
|
33
|
+
const keep = new Set();
|
|
34
|
+
for (const k of changed) for (let d = -context; d <= context; d++) keep.add(k + d);
|
|
35
|
+
const lines = [`--- ${file}`, `+++ ${file}`];
|
|
36
|
+
let last = -2;
|
|
37
|
+
ops.forEach((o, k) => {
|
|
38
|
+
if (!keep.has(k)) return;
|
|
39
|
+
if (k !== last + 1) lines.push(`@@ line ${o[2] ?? o[3]} @@`);
|
|
40
|
+
lines.push(o[0] + o[1]);
|
|
41
|
+
if (o[0] === "+") added++;
|
|
42
|
+
if (o[0] === "-") removed++;
|
|
43
|
+
last = k;
|
|
44
|
+
});
|
|
45
|
+
return { text: lines.join("\n"), added, removed };
|
|
46
|
+
}
|