pindeck 3.0.0-beta.2 → 3.0.0-beta.3
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/README.md +58 -9
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +26 -8
- package/package.json +1 -1
- package/public/bridge.js +248 -69
- package/public/editor.js +7 -3
- package/server.mjs +68 -14
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PinDeck
|
|
2
2
|
|
|
3
|
-
> **macOS, and Linux as a beta · Version 3 beta preview** (`3.0.0-beta.
|
|
3
|
+
> **macOS, and Linux as a beta · Version 3 beta preview** (`3.0.0-beta.3`). On Linux, PinDeck runs as a
|
|
4
4
|
> headless server that you use from your Mac's browser through an SSH tunnel (see
|
|
5
5
|
> [Running on a Linux server](#running-on-a-linux-server-beta)). Windows is not supported. The beta is on
|
|
6
6
|
> npm’s `beta` channel and the [`v3-beta`](https://github.com/ehabets/PinDeck/tree/v3-beta) branch; stable
|
|
@@ -42,7 +42,7 @@ Install the beta from npm:
|
|
|
42
42
|
```bash
|
|
43
43
|
npx pindeck@beta /path/to/deck
|
|
44
44
|
# Or select this exact beta:
|
|
45
|
-
npx pindeck@3.0.0-beta.
|
|
45
|
+
npx pindeck@3.0.0-beta.3 /path/to/deck
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Or run it from the `v3-beta` branch, which includes the example deck:
|
|
@@ -101,7 +101,8 @@ errors; PinDeck does not fall back to broader permissions. Recheck backend compa
|
|
|
101
101
|
|
|
102
102
|
## Preparing a deck
|
|
103
103
|
|
|
104
|
-
PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides
|
|
104
|
+
PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`, and with other
|
|
105
|
+
decks that name their slides (see [Decks made without reveal.js](#decks-made-without-revealjs)). Two
|
|
105
106
|
optional `<meta>` tags in the deck's `<head>` tell it about the design:
|
|
106
107
|
|
|
107
108
|
```html
|
|
@@ -116,6 +117,40 @@ numbers will jump where a slide is hidden.
|
|
|
116
117
|
Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
|
|
117
118
|
`brand.css` in the deck folder is locked for both backends. See `ocean-example/` for a complete deck.
|
|
118
119
|
|
|
120
|
+
### Decks made without reveal.js
|
|
121
|
+
|
|
122
|
+
A deck with a script of its own, or made with a framework whose slides are elements of their own
|
|
123
|
+
(Shower, deck.js, a hand-written deck), can be edited too once it names its slides in its `<head>`:
|
|
124
|
+
|
|
125
|
+
```html
|
|
126
|
+
<meta name="pindeck:slides" content="main.deck > section.slide"> <!-- a CSS selector for the slides -->
|
|
127
|
+
<meta name="pindeck:size" content="1440x1080"> <!-- their size in px -->
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The selector may use tag names, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by spaces or `>`,
|
|
131
|
+
and a list of them separated by commas. In the editor, PinDeck shows the slides itself, one at a time and
|
|
132
|
+
scaled to the window, and switches off the deck's own scaling, slide keys and inert or hidden marks while
|
|
133
|
+
you edit; that changes nothing in the deck file. Everything in [Working with it](#working-with-it) works as
|
|
134
|
+
with a reveal.js deck, with these differences:
|
|
135
|
+
|
|
136
|
+
* Build steps (`.fragment`) belong to reveal.js: other decks show every element at once.
|
|
137
|
+
* Hidden slides (`data-visibility="hidden"`) are left out of PinDeck's PDF exports; whether presenting
|
|
138
|
+
skips them is up to the deck's own script.
|
|
139
|
+
* Speaker notes are an `<aside class="notes">` in the slide, as in reveal.js. PinDeck hides them in the
|
|
140
|
+
editor; the deck's CSS should hide them too (`aside.notes { display: none }`).
|
|
141
|
+
* **Present** opens the deck at `#<slide id>`, or `#<number>` (counting from 1) for a slide without an id.
|
|
142
|
+
Present may open on the first slide if the deck does not go to slides by their address.
|
|
143
|
+
* **+ New slide** models a new slide on the deck's own (see [Making your own template](#making-your-own-template)):
|
|
144
|
+
a content slide gets placeholder bullets in a `.slide-body`; without one it has only its title. A new or
|
|
145
|
+
duplicated slide does not take the original's `id`.
|
|
146
|
+
|
|
147
|
+
Both tags are needed: PinDeck opens the slides at the given size for screenshots and PDF pages. A missing
|
|
148
|
+
size, or a selector that finds no slides or that PinDeck cannot read, is reported before anything changes.
|
|
149
|
+
While PinDeck runs, it reads the tags again for every screenshot, PDF and assistant session; the editor
|
|
150
|
+
reads them when the slide reloads. If a tag stops working meanwhile, the editor shows the same message and
|
|
151
|
+
the assistant is told. A session already running keeps the slide selector it started with; **New chat**
|
|
152
|
+
starts one with the new selector.
|
|
153
|
+
|
|
119
154
|
## Making your own template
|
|
120
155
|
|
|
121
156
|
PinDeck has no template generator; a template is simply a deck folder. Start from a copy of the example:
|
|
@@ -448,8 +483,10 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
448
483
|
|
|
449
484
|
## Current Limitations
|
|
450
485
|
|
|
451
|
-
* reveal.js decks
|
|
452
|
-
|
|
486
|
+
* reveal.js decks, and decks that name their slides with `<meta name="pindeck:slides">` (see [Decks made
|
|
487
|
+
without reveal.js](#decks-made-without-revealjs)). Any other deck made with another framework (Marp, Slidev,
|
|
488
|
+
impress.js, remark, Spectacle, Shower, deck.js, bespoke.js) is recognised and refused with its name before
|
|
489
|
+
PinDeck changes anything.
|
|
453
490
|
* Text inside inline SVG diagrams cannot be edited in place; pin it and ask the assistant.
|
|
454
491
|
* Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
|
|
455
492
|
* 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 the assistant for those.
|
|
@@ -461,6 +498,12 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
461
498
|
|
|
462
499
|
## Versions
|
|
463
500
|
|
|
501
|
+
* **3.0.0-beta.3**: everything from 2.10.0, for both assistants.
|
|
502
|
+
- Decks made without reveal.js: a deck that names its slides with `<meta name="pindeck:slides">`
|
|
503
|
+
and their size with `<meta name="pindeck:size">` is edited like a reveal.js deck (contributed by
|
|
504
|
+
Zeyu Xu). Claude and Codex are both told what the deck's slides are.
|
|
505
|
+
- Screenshots fill the picture instead of being letterboxed into a shorter page.
|
|
506
|
+
|
|
464
507
|
* **3.0.0-beta.2**: everything from 2.9 and 2.10, with Codex brought up to it.
|
|
465
508
|
- Security: the fix from 2.9. The editor page no longer contains the token, and the deck needs it
|
|
466
509
|
too, so other users of the same machine cannot read either. PinDeck prints a one-time login link at
|
|
@@ -480,8 +523,8 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
480
523
|
switching with optional handover, curated model lists, a separate effort selector with per-model
|
|
481
524
|
preferences, restricted Codex tools, and separate v3 runtime state. On npm as `pindeck@beta`.
|
|
482
525
|
|
|
483
|
-
* **2.10
|
|
484
|
-
on the whole deck.
|
|
526
|
+
* **2.10**: decks with their own file names and shared files, decks made without reveal.js, and Claude
|
|
527
|
+
working on the whole deck.
|
|
485
528
|
- A deck file by name: `pindeck path/to/talk.html` (a folder still opens its `index.html`). Several
|
|
486
529
|
decks can share a folder, each with its own chat and undo history.
|
|
487
530
|
- Claude may edit and create other files of the deck (scripts, style sheets, notes), never
|
|
@@ -492,8 +535,14 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
492
535
|
screenshots and PDF exports. A symlink that leads out of the deck and its asset roots is refused.
|
|
493
536
|
- Shared instructions: the deck's `CLAUDE.md` and `AGENTS.md` may import other files with `@path`.
|
|
494
537
|
`CLAUDE.md` files in parent folders are still not loaded, on purpose (see the README).
|
|
495
|
-
- Decks made
|
|
496
|
-
|
|
538
|
+
- Decks made without reveal.js (a script of their own, Shower, deck.js, a hand-written deck): a deck
|
|
539
|
+
that names its slides with `<meta name="pindeck:slides">` (a CSS selector) and their size with
|
|
540
|
+
`<meta name="pindeck:size">` is edited like a reveal.js deck, with screenshots at the slide size and
|
|
541
|
+
one PDF page per slide (contributed by Zeyu Xu). Decks made with other slide frameworks (Marp,
|
|
542
|
+
Slidev, impress.js, remark and others) without these tags are recognised and refused with their
|
|
543
|
+
name, before PinDeck changes anything; the message says how to add the tags.
|
|
544
|
+
- Screenshots fill the picture: headless Chrome lays the page out shorter than its window, which
|
|
545
|
+
left the slide letterboxed in some Chrome versions (reported by Zeyu Xu).
|
|
497
546
|
- Linux: pictures of PDF pages with Poppler, the PowerPoint import with slide pictures from
|
|
498
547
|
LibreOffice, and waveforms of non-WAV audio with ffmpeg, each when installed.
|
|
499
548
|
|
package/lib/eids.mjs
CHANGED
|
@@ -145,8 +145,97 @@ function find(node, pred) {
|
|
|
145
145
|
return hit;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
/*
|
|
148
|
+
/* ---- Decks made without reveal.js ----------------------------------------------------------------
|
|
149
|
+
A deck names its slides with <meta name="pindeck:slides" content="main.deck > section.slide">, a CSS
|
|
150
|
+
selector, and its size with <meta name="pindeck:size" content="1440x1080">. Selectors stay simple, so
|
|
151
|
+
the server and the browser find the same slides: tag names, #id, .class, [attr] and [attr="value"],
|
|
152
|
+
joined by spaces (descendant) or ">" (child), in a comma-separated list. */
|
|
153
|
+
|
|
154
|
+
const metaOf = (tree, name) => {
|
|
155
|
+
const m = find(tree, (c) => c.name === "meta" && c.attrs.name?.value.trim().toLowerCase() === `pindeck:${name}`);
|
|
156
|
+
return m ? decode(m.attrs.content?.value || "").trim() || null : null;
|
|
157
|
+
};
|
|
158
|
+
/* The deck's slide selector, or null for a reveal.js deck */
|
|
159
|
+
export const slideSelector = (tree) => metaOf(tree, "slides");
|
|
160
|
+
/* The deck's slide size from <meta name="pindeck:size">: { width, height } or null */
|
|
161
|
+
export function deckSize(tree) {
|
|
162
|
+
const m = /^(\d{2,5})\s*[x×*,\s]\s*(\d{2,5})$/i.exec(metaOf(tree, "size") || "");
|
|
163
|
+
return m ? { width: +m[1], height: +m[2] } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const IDENT = "-?[_a-zA-Z\\u00a0-\\uffff][\\w\\u00a0-\\uffff-]*";
|
|
167
|
+
const COMPOUND = new RegExp(`^(?:(\\*|${IDENT})|#(${IDENT})|\\.(${IDENT})|\\[\\s*(${IDENT})\\s*(?:=\\s*(?:"([^"]*)"|'([^']*)'|(${IDENT}))\\s*)?\\])`);
|
|
168
|
+
/* "main.deck > section.slide, aside.extra" -> one list of compounds and combinators per selector.
|
|
169
|
+
Throws with the part it cannot read (pseudo-classes, +, ~ ...). */
|
|
170
|
+
export function parseSelector(sel) {
|
|
171
|
+
const parts = []; // top-level commas only: not inside [attr="a,b"]
|
|
172
|
+
let quote = null, depth = 0, from = 0;
|
|
173
|
+
for (let i = 0; i < sel.length; i++) {
|
|
174
|
+
const c = sel[i];
|
|
175
|
+
if (quote) { if (c === quote) quote = null; }
|
|
176
|
+
else if (c === '"' || c === "'") quote = c;
|
|
177
|
+
else if (c === "[") depth++;
|
|
178
|
+
else if (c === "]") depth--;
|
|
179
|
+
else if (c === "," && !depth) { parts.push(sel.slice(from, i)); from = i + 1; }
|
|
180
|
+
}
|
|
181
|
+
parts.push(sel.slice(from));
|
|
182
|
+
return parts.map((part) => {
|
|
183
|
+
const steps = []; // [{ tag, ids, classes, attrs }, ">" | " ", { ... }, ...]
|
|
184
|
+
let s = part.trim(), cur = null;
|
|
185
|
+
if (!s) throw new Error(`empty selector in "${sel}"`);
|
|
186
|
+
while (s) {
|
|
187
|
+
const ws = /^\s+/.exec(s);
|
|
188
|
+
const gt = /^\s*>\s*/.exec(s);
|
|
189
|
+
if (gt) { if (!cur) throw new Error(`"${part.trim()}" starts with ">"`); steps.push(">"); cur = null; s = s.slice(gt[0].length); continue; }
|
|
190
|
+
if (ws) { steps.push(" "); cur = null; s = s.slice(ws[0].length); continue; }
|
|
191
|
+
const m = COMPOUND.exec(s);
|
|
192
|
+
if (!m) throw new Error(`"${s.slice(0, 20)}" in "${part.trim()}" is not supported (use tags, #id, .class, [attr], spaces and ">")`);
|
|
193
|
+
if (!cur) { cur = { tag: null, ids: [], classes: [], attrs: [] }; steps.push(cur); }
|
|
194
|
+
if (m[1]) {
|
|
195
|
+
if (cur.tag || cur.ids.length || cur.classes.length || cur.attrs.length) throw new Error(`a tag name must come first in "${part.trim()}"`);
|
|
196
|
+
cur.tag = m[1] === "*" ? null : m[1].toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
else if (m[2]) cur.ids.push(m[2]);
|
|
199
|
+
else if (m[3]) cur.classes.push(m[3]);
|
|
200
|
+
else cur.attrs.push({ name: m[4].toLowerCase(), value: m[5] ?? m[6] ?? m[7] ?? null });
|
|
201
|
+
s = s.slice(m[0].length);
|
|
202
|
+
}
|
|
203
|
+
if (typeof steps.at(-1) === "string") throw new Error(`"${part.trim()}" ends with a combinator`);
|
|
204
|
+
return steps;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function compoundMatches(node, c) {
|
|
208
|
+
if (!node.attrs) return false; // the root
|
|
209
|
+
if (c.tag && node.name !== c.tag) return false;
|
|
210
|
+
if (c.ids.some((id) => node.attrs.id?.value !== id)) return false;
|
|
211
|
+
const cls = classesOf(node);
|
|
212
|
+
if (c.classes.some((k) => !cls.includes(k))) return false;
|
|
213
|
+
return c.attrs.every((a) => node.attrs[a.name] && (a.value == null || node.attrs[a.name].value === a.value));
|
|
214
|
+
}
|
|
215
|
+
/* steps[i] matches node, and the steps before it match its ancestors as their combinators say */
|
|
216
|
+
function stepsMatch(node, steps, i = steps.length - 1) {
|
|
217
|
+
if (!compoundMatches(node, steps[i])) return false;
|
|
218
|
+
if (i === 0) return true;
|
|
219
|
+
if (steps[i - 1] === ">") return !!node.parent && stepsMatch(node.parent, steps, i - 2);
|
|
220
|
+
for (let a = node.parent; a; a = a.parent) if (stepsMatch(a, steps, i - 2)) return true;
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
/* Elements matching the selector, in document order; an element holding another match is a stack of
|
|
224
|
+
slides, not a slide (as with reveal.js's vertical stacks) */
|
|
225
|
+
function selectorSlides(tree, sel) {
|
|
226
|
+
let list;
|
|
227
|
+
try { list = parseSelector(sel); } catch { return []; }
|
|
228
|
+
const hits = [];
|
|
229
|
+
walk(tree, (c) => { if (list.some((steps) => stepsMatch(c, steps))) hits.push(c); });
|
|
230
|
+
const inside = (a, b) => { for (let p = b.parent; p; p = p.parent) if (p === a) return true; return false; };
|
|
231
|
+
return hits.filter((h) => !hits.some((o) => o !== h && inside(h, o)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. A deck that names
|
|
235
|
+
its slides with <meta name="pindeck:slides"> has the elements that selector matches instead. */
|
|
149
236
|
export function findSlides(tree) {
|
|
237
|
+
const sel = slideSelector(tree);
|
|
238
|
+
if (sel) return selectorSlides(tree, sel);
|
|
150
239
|
const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
|
|
151
240
|
if (!slidesEl) return [];
|
|
152
241
|
const out = [];
|
|
@@ -187,6 +276,7 @@ function elementName(node) {
|
|
|
187
276
|
export function assignEids(html) {
|
|
188
277
|
const tree = parse(html);
|
|
189
278
|
const sections = findSlides(tree);
|
|
279
|
+
const isSlide = slideSelector(tree) ? ((c) => sections.includes(c)) : ((c) => c.name === "section");
|
|
190
280
|
const used = new Set();
|
|
191
281
|
const edits = []; // { at, insert } or { start, end, replace }
|
|
192
282
|
const assigned = [], renamed = [];
|
|
@@ -240,7 +330,7 @@ export function assignEids(html) {
|
|
|
240
330
|
// Pass 2: elements inside the slide
|
|
241
331
|
const visit = (node, inSvg) => {
|
|
242
332
|
for (const c of node.children) {
|
|
243
|
-
if (c
|
|
333
|
+
if (isSlide(c)) continue; // nested slide, handled on its own
|
|
244
334
|
const cls = classesOf(c);
|
|
245
335
|
if (c.name === "aside" && cls.includes("notes")) continue;
|
|
246
336
|
if (c.attrs["data-noeid"]) continue;
|
|
@@ -423,10 +513,11 @@ const slideKind = (n) => classList(n).find((c) => /-slide$/.test(c)) || "content
|
|
|
423
513
|
content title and two placeholder bullets, from a content slide (the current one if it is one)
|
|
424
514
|
section a section divider, from the deck's first divider
|
|
425
515
|
duplicate a copy of the current slide
|
|
426
|
-
Element ids are left out; assignEids() gives the new slide fresh ones.
|
|
516
|
+
Element ids are left out; assignEids() gives the new slide fresh ones. In a deck made without reveal.js,
|
|
517
|
+
the slide's own id is left out too: such decks (and Present) go to slides by their id.
|
|
427
518
|
Returns { html, index } (index of the new slide) or { error }. */
|
|
428
519
|
export function insertSlide(html, kind, afterEid) {
|
|
429
|
-
const
|
|
520
|
+
const tree = parse(html), slides = findSlides(tree);
|
|
430
521
|
if (!slides.length) return { error: "The deck has no slides." };
|
|
431
522
|
const after = slides.find((s) => s.attrs["data-eid"]?.value === afterEid) || slides.at(-1);
|
|
432
523
|
const hasHead = (s) => { let h = false; walk({ children: [s] }, (c) => { if (/^h[1-3]$/.test(c.name)) h = true; }); return h; };
|
|
@@ -437,6 +528,7 @@ export function insertSlide(html, kind, afterEid) {
|
|
|
437
528
|
if (!tpl) return { error: kind === "section" ? "This deck has no section divider to model a new one on." : "This deck has no content slide to model a new one on." };
|
|
438
529
|
|
|
439
530
|
let frag = html.slice(tpl.start, elementEnd(html, tpl)).replace(/\s+data-eid="[^"]*"/g, "");
|
|
531
|
+
if (slideSelector(tree)) frag = frag.replace(/^(<[^>]*?)\s+id=(?:"[^"]*"|'[^']*'|[^\s>]+)/, "$1"); // the opening tag only
|
|
440
532
|
if (kind !== "duplicate") frag = placeholderSlide(frag, kind, slides.filter((s, i) => i <= slides.indexOf(after) && slideKind(s) === "section-slide").length + 1);
|
|
441
533
|
|
|
442
534
|
const indent = lineIndent(html, after.start);
|
|
@@ -500,12 +592,13 @@ function slideBlock(html, s) {
|
|
|
500
592
|
are. Slides inside vertical stacks are not moved. Returns { html, changed, from, index } or { error }. */
|
|
501
593
|
export function moveSlide(html, eid, beforeEid) {
|
|
502
594
|
const byEid = (all, e) => all.find((s) => s.attrs["data-eid"]?.value === e);
|
|
503
|
-
const
|
|
595
|
+
const tree = parse(html), slides = findSlides(tree);
|
|
596
|
+
const bySelector = !!slideSelector(tree); // such decks have no vertical stacks
|
|
504
597
|
const slide = byEid(slides, eid), target = beforeEid == null ? null : byEid(slides, beforeEid);
|
|
505
598
|
if (!slide || (beforeEid != null && !target)) return { error: "Slide not found in the file." };
|
|
506
|
-
if ([slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
|
|
599
|
+
if (!bySelector && [slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
|
|
507
600
|
const from = slides.indexOf(slide);
|
|
508
|
-
const top = slide.parent.children.filter((c) => c.name === "section");
|
|
601
|
+
const top = slide.parent.children.filter((c) => (bySelector ? slides.includes(c) : c.name === "section"));
|
|
509
602
|
const next = top[top.indexOf(slide) + 1] || null;
|
|
510
603
|
if (target === slide || target === next) return { html, changed: false, from, index: from };
|
|
511
604
|
|
|
@@ -520,7 +613,7 @@ export function moveSlide(html, eid, beforeEid) {
|
|
|
520
613
|
out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
|
|
521
614
|
} else {
|
|
522
615
|
// A final vertical stack is absent from the leaf-slide list, but we must insert after it.
|
|
523
|
-
const last = find(parse(cut), (c) => classesOf(c).includes("slides"))
|
|
616
|
+
const last = bySelector ? rest.at(-1) : find(parse(cut), (c) => classesOf(c).includes("slides"))
|
|
524
617
|
.children.filter((c) => c.name === "section").at(-1);
|
|
525
618
|
const at = elementEnd(cut, last);
|
|
526
619
|
out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
|
package/lib/frameworks.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/* Which slide framework a deck uses. PinDeck edits reveal.js decks: <section> slides inside an element
|
|
2
|
-
with class "slides" (inside <div class="reveal">).
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
with class "slides" (inside <div class="reveal">). Any other deck can name its slides itself with
|
|
3
|
+
<meta name="pindeck:slides" content="CSS selector"> (see lib/eids.mjs) and is then edited too. Other
|
|
4
|
+
frameworks are recognised by their markup and scripts, so the user gets a clear "not supported (yet)"
|
|
5
|
+
before PinDeck changes anything, instead of an editor without slides. Only tags and scripts are looked
|
|
6
|
+
at, never the slides' text, so a talk that mentions "impress.js" is not taken for one. */
|
|
6
7
|
|
|
7
|
-
import { parse, findSlides } from "./eids.mjs";
|
|
8
|
+
import { parse, findSlides, slideSelector, deckSize, parseSelector } from "./eids.mjs";
|
|
8
9
|
|
|
9
10
|
const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.js, which it is built on)
|
|
10
11
|
["Marp", /\bmarpit\b|bespoke-marp|data-marpit|marp-cli/i],
|
|
@@ -20,9 +21,13 @@ const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.j
|
|
|
20
21
|
/* Tags and script contents only: the slides' text could name any framework */
|
|
21
22
|
const markup = (html) => [...String(html).matchAll(/<script\b[^>]*>[\s\S]*?<\/script>|<[^>]+>/gi)].map((m) => m[0]).join("\n");
|
|
22
23
|
|
|
23
|
-
/* { name, supported }: reveal.js decks are supported, including one without slides yet
|
|
24
|
+
/* { name, supported }: reveal.js decks are supported, including one without slides yet, and so is a deck
|
|
25
|
+
whose <meta name="pindeck:slides"> selector finds slides and whose <meta name="pindeck:size"> gives their
|
|
26
|
+
size ({ name: "selector", selector, supported }) */
|
|
24
27
|
export function detectFramework(html) {
|
|
25
|
-
|
|
28
|
+
const tree = parse(html), selector = slideSelector(tree);
|
|
29
|
+
if (selector) return { name: "selector", selector, supported: findSlides(tree).length > 0 && !!deckSize(tree) };
|
|
30
|
+
if (findSlides(tree).length) return { name: "reveal.js", supported: true };
|
|
26
31
|
const tags = markup(html);
|
|
27
32
|
const other = OTHERS.find(([, re]) => re.test(tags));
|
|
28
33
|
if (other) return { name: other[0], supported: false };
|
|
@@ -30,12 +35,25 @@ export function detectFramework(html) {
|
|
|
30
35
|
return { name: null, supported: false };
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
const HOW = `A deck made otherwise can name its slides in its <head>: ` +
|
|
39
|
+
`<meta name="pindeck:slides" content="main.deck > section.slide"> (a CSS selector for the slides) and ` +
|
|
40
|
+
`<meta name="pindeck:size" content="1440x1080"> (their size in px).`;
|
|
41
|
+
|
|
33
42
|
/* The message for a deck PinDeck cannot edit, or null */
|
|
34
43
|
export function unsupportedMessage(html, file) {
|
|
35
44
|
const fw = detectFramework(html);
|
|
36
45
|
if (fw.supported) return null;
|
|
46
|
+
if (fw.name === "selector") {
|
|
47
|
+
// the slides are found: the size is what is missing (headless Chrome and the PDF pages need it)
|
|
48
|
+
if (findSlides(parse(html)).length) return `${file}: <meta name="pindeck:size"> is missing or not a size ` +
|
|
49
|
+
`such as content="1440x1080" (the slides' width and height in px). PinDeck opens the slides at that size ` +
|
|
50
|
+
`for screenshots and PDFs. Nothing was changed.`;
|
|
51
|
+
let why = "matches no elements";
|
|
52
|
+
try { parseSelector(fw.selector); } catch (e) { why = `cannot be used: ${e.message}`; }
|
|
53
|
+
return `${file}: the slide selector "${fw.selector}" in <meta name="pindeck:slides"> ${why}. Nothing was changed.`;
|
|
54
|
+
}
|
|
37
55
|
const what = fw.name ? `${file} is a ${fw.name} deck.` :
|
|
38
56
|
`${file} has no reveal.js slides (a <div class="reveal"> with <div class="slides"> holding <section> slides).`;
|
|
39
|
-
return `${what} PinDeck works with reveal.js decks
|
|
57
|
+
return `${what} PinDeck works with reveal.js decks; other frameworks may follow. ${HOW} ` +
|
|
40
58
|
`Nothing was changed.`;
|
|
41
59
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.3",
|
|
4
4
|
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
package/public/bridge.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
main the visible slide in the editor: navigation, selection, drag and resize, text editing, pins
|
|
5
5
|
measure hidden 1280x720 copy used to measure any slide without moving the user's view
|
|
6
6
|
shot headless screenshot of one slide, with pin markers
|
|
7
|
-
dump headless measurement; writes the result into the DOM for --dump-dom
|
|
7
|
+
dump headless measurement; writes the result into the DOM for --dump-dom
|
|
8
|
+
pdf print layout for the PDF export (decks made without reveal.js; reveal.js has its own) */
|
|
8
9
|
|
|
9
10
|
(function () {
|
|
10
11
|
const params = new URLSearchParams(location.search);
|
|
@@ -14,64 +15,69 @@
|
|
|
14
15
|
const deckMeta = (n) => document.querySelector(`meta[name="pindeck:${n}"]`)?.content?.trim();
|
|
15
16
|
const FOOTER_TOP = deckMeta("footer-top") ? +deckMeta("footer-top") : null;
|
|
16
17
|
const LOCKED_SEL = deckMeta("locked") || ".slide-foot, .logo";
|
|
18
|
+
// A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (a CSS selector)
|
|
19
|
+
const SLIDE_SEL = deckMeta("slides") || null;
|
|
20
|
+
const SLIDE = SLIDE_SEL ? `:is(${SLIDE_SEL})` : ".reveal .slides section"; // in CSS and closest()
|
|
21
|
+
const SLIDE_OF = SLIDE_SEL || "section"; // the slide an element is on
|
|
17
22
|
const host = () => (window.parent !== window ? window.parent.__editorHost : null);
|
|
18
|
-
let W = 1280, H = 720;
|
|
23
|
+
let W = 1280, H = 720, deck = null; // deck: reveal.js or a selector deck (see "Decks" below)
|
|
19
24
|
|
|
20
25
|
/* ---- Styles for editor-only overlays ---------------------------------------- */
|
|
21
26
|
const css = document.createElement("style");
|
|
22
|
-
// --u = screen pixels per slide pixel, inverted: overlays keep a constant on-screen size at any
|
|
27
|
+
// --pindeck-u = screen pixels per slide pixel, inverted: overlays keep a constant on-screen size at any
|
|
28
|
+
// zoom (a name of its own: decks use --u for their own units)
|
|
23
29
|
css.textContent = `
|
|
24
|
-
.__pin { position:absolute; z-index:1000; width:calc(26px*var(--u,1)); height:calc(26px*var(--u,1));
|
|
25
|
-
margin:calc(-13px*var(--u,1)) 0 0 calc(-13px*var(--u,1)); border-radius:50%;
|
|
26
|
-
background:#ED8C01; color:#fff; font:700 calc(14px*var(--u,1))/calc(26px*var(--u,1)) system-ui, sans-serif; text-align:center;
|
|
27
|
-
box-shadow:0 0 0 calc(2px*var(--u,1)) #fff, 0 calc(2px*var(--u,1)) calc(6px*var(--u,1)) rgba(0,0,0,.45);
|
|
30
|
+
.__pin { position:absolute; z-index:1000; width:calc(26px*var(--pindeck-u,1)); height:calc(26px*var(--pindeck-u,1));
|
|
31
|
+
margin:calc(-13px*var(--pindeck-u,1)) 0 0 calc(-13px*var(--pindeck-u,1)); border-radius:50%;
|
|
32
|
+
background:#ED8C01; color:#fff; font:700 calc(14px*var(--pindeck-u,1))/calc(26px*var(--pindeck-u,1)) system-ui, sans-serif; text-align:center;
|
|
33
|
+
box-shadow:0 0 0 calc(2px*var(--pindeck-u,1)) #fff, 0 calc(2px*var(--pindeck-u,1)) calc(6px*var(--pindeck-u,1)) rgba(0,0,0,.45);
|
|
28
34
|
cursor:pointer; pointer-events:auto; }
|
|
29
|
-
.__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--u,1)) dashed #ED8C01;
|
|
30
|
-
outline-offset:calc(2px*var(--u,1)); background:rgba(237,140,1,.06); }
|
|
31
|
-
.__hover span { position:absolute; left:calc(-2px*var(--u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
|
|
32
|
-
font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
|
|
33
|
-
border-radius:calc(3px*var(--u,1)); }
|
|
34
|
-
.__hover span.above { top:calc(-21px*var(--u,1)); } .__hover span.below { bottom:calc(-21px*var(--u,1)); }
|
|
35
|
+
.__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--pindeck-u,1)) dashed #ED8C01;
|
|
36
|
+
outline-offset:calc(2px*var(--pindeck-u,1)); background:rgba(237,140,1,.06); }
|
|
37
|
+
.__hover span { position:absolute; left:calc(-2px*var(--pindeck-u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
|
|
38
|
+
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));
|
|
39
|
+
border-radius:calc(3px*var(--pindeck-u,1)); }
|
|
40
|
+
.__hover span.above { top:calc(-21px*var(--pindeck-u,1)); } .__hover span.below { bottom:calc(-21px*var(--pindeck-u,1)); }
|
|
35
41
|
.__hover.locked { outline-color:#888; background:rgba(0,0,0,.04); }
|
|
36
42
|
.__hover.locked span { background:#888; }
|
|
37
|
-
.__flash { position:absolute; z-index:998; pointer-events:none; outline:calc(3px*var(--u,1)) solid #ED8C01;
|
|
38
|
-
outline-offset:calc(3px*var(--u,1)); animation:__flash 1.4s ease-out forwards; }
|
|
43
|
+
.__flash { position:absolute; z-index:998; pointer-events:none; outline:calc(3px*var(--pindeck-u,1)) solid #ED8C01;
|
|
44
|
+
outline-offset:calc(3px*var(--pindeck-u,1)); animation:__flash 1.4s ease-out forwards; }
|
|
39
45
|
@keyframes __flash { 0%,60% { opacity:1 } 100% { opacity:0 } }
|
|
40
|
-
html.__pinmode
|
|
41
|
-
html.__pinmode
|
|
42
|
-
.__hover.sel { outline:calc(1px*var(--u,1)) solid #1a73e8; outline-offset:0; background:none; }
|
|
46
|
+
html.__pinmode ${SLIDE}, html.__pinmode ${SLIDE} * { cursor:crosshair !important; }
|
|
47
|
+
html.__pinmode ${SLIDE} .__pin { cursor:pointer !important; }
|
|
48
|
+
.__hover.sel { outline:calc(1px*var(--pindeck-u,1)) solid #1a73e8; outline-offset:0; background:none; }
|
|
43
49
|
.__hover.sel span { background:#1a73e8; }
|
|
44
|
-
.__sel { position:absolute; z-index:1001; pointer-events:none; outline:calc(1.5px*var(--u,1)) solid #1a73e8; }
|
|
45
|
-
.__sel i { position:absolute; width:calc(9px*var(--u,1)); height:calc(9px*var(--u,1)); margin:calc(-5px*var(--u,1)) 0 0 calc(-5px*var(--u,1));
|
|
46
|
-
background:#fff; border:calc(1.5px*var(--u,1)) solid #1a73e8; box-sizing:border-box; pointer-events:auto; }
|
|
50
|
+
.__sel { position:absolute; z-index:1001; pointer-events:none; outline:calc(1.5px*var(--pindeck-u,1)) solid #1a73e8; }
|
|
51
|
+
.__sel i { position:absolute; width:calc(9px*var(--pindeck-u,1)); height:calc(9px*var(--pindeck-u,1)); margin:calc(-5px*var(--pindeck-u,1)) 0 0 calc(-5px*var(--pindeck-u,1));
|
|
52
|
+
background:#fff; border:calc(1.5px*var(--pindeck-u,1)) solid #1a73e8; box-sizing:border-box; pointer-events:auto; }
|
|
47
53
|
.__sel i[data-h="nw"] { left:0; top:0; cursor:nwse-resize; } .__sel i[data-h="n"] { left:50%; top:0; cursor:ns-resize; }
|
|
48
54
|
.__sel i[data-h="ne"] { left:100%; top:0; cursor:nesw-resize; } .__sel i[data-h="e"] { left:100%; top:50%; cursor:ew-resize; }
|
|
49
55
|
.__sel i[data-h="se"] { left:100%; top:100%; cursor:nwse-resize; } .__sel i[data-h="s"] { left:50%; top:100%; cursor:ns-resize; }
|
|
50
56
|
.__sel i[data-h="sw"] { left:0; top:100%; cursor:nesw-resize; } .__sel i[data-h="w"] { left:0; top:50%; cursor:ew-resize; }
|
|
51
57
|
.__sel.readonly i { display:none; }
|
|
52
|
-
.__sel-label { position:absolute; left:calc(-1.5px*var(--u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
|
|
53
|
-
font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
|
|
54
|
-
border-radius:calc(3px*var(--u,1)); font-variant-numeric:tabular-nums; }
|
|
55
|
-
.__sel-label.above { top:calc(-21px*var(--u,1)); } .__sel-label.below { bottom:calc(-21px*var(--u,1)); }
|
|
58
|
+
.__sel-label { position:absolute; left:calc(-1.5px*var(--pindeck-u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
|
|
59
|
+
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));
|
|
60
|
+
border-radius:calc(3px*var(--pindeck-u,1)); font-variant-numeric:tabular-nums; }
|
|
61
|
+
.__sel-label.above { top:calc(-21px*var(--pindeck-u,1)); } .__sel-label.below { bottom:calc(-21px*var(--pindeck-u,1)); }
|
|
56
62
|
.__guide { position:absolute; z-index:1002; pointer-events:none; background:#e5399d; }
|
|
57
|
-
.__guide.v { top:0; height:100%; width:calc(1px*var(--u,1)); }
|
|
58
|
-
.__guide.h { left:0; width:100%; height:calc(1px*var(--u,1)); }
|
|
59
|
-
html.__selmode
|
|
60
|
-
html.__selmode
|
|
61
|
-
html.__selmode
|
|
62
|
-
.__editing { outline:calc(2px*var(--u,1)) solid #1a73e8 !important; outline-offset:calc(3px*var(--u,1));
|
|
63
|
+
.__guide.v { top:0; height:100%; width:calc(1px*var(--pindeck-u,1)); }
|
|
64
|
+
.__guide.h { left:0; width:100%; height:calc(1px*var(--pindeck-u,1)); }
|
|
65
|
+
html.__selmode ${SLIDE} * { cursor:default; }
|
|
66
|
+
html.__selmode ${SLIDE} .__selected, html.__selmode.__dragging * { cursor:move !important; }
|
|
67
|
+
html.__selmode ${SLIDE} .__sel i { cursor:inherit; }
|
|
68
|
+
.__editing { outline:calc(2px*var(--pindeck-u,1)) solid #1a73e8 !important; outline-offset:calc(3px*var(--pindeck-u,1));
|
|
63
69
|
background:rgba(26,115,232,.04); cursor:text !important; caret-color:#1a73e8; }
|
|
64
70
|
.__editing * { cursor:text !important; }
|
|
65
|
-
.__hidden-banner { position:absolute; z-index:997; top:calc(26px*var(--u,1)); right:calc(26px*var(--u,1)); pointer-events:none;
|
|
66
|
-
background:#ED8C01; color:#fff; font:600 calc(12px*var(--u,1))/calc(22px*var(--u,1)) system-ui, sans-serif;
|
|
67
|
-
padding:0 calc(10px*var(--u,1)); border-radius:calc(11px*var(--u,1)); box-shadow:0 calc(2px*var(--u,1)) calc(8px*var(--u,1)) rgba(0,0,0,.25); }
|
|
71
|
+
.__hidden-banner { position:absolute; z-index:997; top:calc(26px*var(--pindeck-u,1)); right:calc(26px*var(--pindeck-u,1)); pointer-events:none;
|
|
72
|
+
background:#ED8C01; color:#fff; font:600 calc(12px*var(--pindeck-u,1))/calc(22px*var(--pindeck-u,1)) system-ui, sans-serif;
|
|
73
|
+
padding:0 calc(10px*var(--pindeck-u,1)); border-radius:calc(11px*var(--pindeck-u,1)); box-shadow:0 calc(2px*var(--pindeck-u,1)) calc(8px*var(--pindeck-u,1)) rgba(0,0,0,.25); }
|
|
68
74
|
.__edit-hint { position:absolute; z-index:1003; pointer-events:none; white-space:nowrap; background:#1a73e8; color:#fff;
|
|
69
|
-
font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
|
|
70
|
-
border-radius:calc(3px*var(--u,1)); }`;
|
|
75
|
+
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));
|
|
76
|
+
border-radius:calc(3px*var(--pindeck-u,1)); }`;
|
|
71
77
|
document.head.appendChild(css);
|
|
72
78
|
|
|
73
79
|
/* ---- Geometry ------------------------------------------------------------------ */
|
|
74
|
-
const slides = () =>
|
|
80
|
+
const slides = () => deck.slides();
|
|
75
81
|
const indexOf = (sec) => slides().indexOf(sec);
|
|
76
82
|
const r1 = (v) => Math.round(v);
|
|
77
83
|
function frame(sec) {
|
|
@@ -122,7 +128,7 @@
|
|
|
122
128
|
hasFooter: !!sec.querySelector(".slide-foot"), hidden };
|
|
123
129
|
}
|
|
124
130
|
function info() {
|
|
125
|
-
return { slides: slides().map((s, i) => slideInfo(s, i)), current: indexOf(
|
|
131
|
+
return { slides: slides().map((s, i) => slideInfo(s, i)), current: indexOf(deck.current()) };
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
/* ---- Layout measurement ------------------------------------------------------------- */
|
|
@@ -138,7 +144,7 @@
|
|
|
138
144
|
const hidden = cs.display === "none" || cs.visibility === "hidden" || +cs.opacity === 0 || (b.w === 0 && b.h === 0);
|
|
139
145
|
const leaf = !el.querySelector("[data-eid]");
|
|
140
146
|
const own = el === sec ? "" : textOf(el, !leaf);
|
|
141
|
-
const tag = el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !/^(fragment|visible|current-fragment|present|past|future)$/.test(c)).join(".") : "");
|
|
147
|
+
const tag = el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !/^(fragment|visible|current-fragment|present|past|future)$/.test(c) && !c.startsWith("__")).join(".") : "");
|
|
142
148
|
const e = { eid: el.dataset.eid || si.eid, tag: tag.replace(/\.$/, ""), depth, ...b };
|
|
143
149
|
if (own && el.tagName !== "IMG" && el !== sec) e.font = fontOf(el);
|
|
144
150
|
if (own) e.text = own;
|
|
@@ -218,13 +224,8 @@
|
|
|
218
224
|
const nextFrame = () => Promise.resolve();
|
|
219
225
|
|
|
220
226
|
/* ---- Navigation ------------------------------------------------------------------------ */
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (!sec) return;
|
|
224
|
-
const ix = Reveal.getIndices(sec);
|
|
225
|
-
Reveal.slide(ix.h, ix.v);
|
|
226
|
-
}
|
|
227
|
-
function indices(index) { const sec = slides()[index]; return sec ? Reveal.getIndices(sec) : { h: 0, v: 0 }; }
|
|
227
|
+
const goto = (index) => deck.goto(index);
|
|
228
|
+
const presentHash = (index) => deck.hash(index); // the slide's address in the deck opened on its own
|
|
228
229
|
|
|
229
230
|
/* ---- Pins and hover ---------------------------------------------------------------------- */
|
|
230
231
|
let mode = "select", pins = [], editable = true; // mode: "select" (drag, resize) or "pin"
|
|
@@ -249,12 +250,12 @@
|
|
|
249
250
|
}
|
|
250
251
|
function targetOf(t) {
|
|
251
252
|
if (!t || !t.closest) return null;
|
|
252
|
-
const sec = t.closest(
|
|
253
|
+
const sec = t.closest(SLIDE);
|
|
253
254
|
if (!sec || t.closest(".__pin, .__hover, .__sel, .__guide")) return null;
|
|
254
255
|
const foot = t.closest(".slide-foot");
|
|
255
256
|
const el = foot ? sec : t.closest("[data-eid]") || sec;
|
|
256
257
|
const locked = foot ? "footer, set by brand.css (locked)" : el !== sec && el.matches(LOCKED_SEL) ? "positioned by brand.css (locked)" : null;
|
|
257
|
-
return { sec: el === sec ? sec : el.closest(
|
|
258
|
+
return { sec: el === sec ? sec : el.closest(SLIDE_OF), el, locked, foot };
|
|
258
259
|
}
|
|
259
260
|
function onPointerDown(e) {
|
|
260
261
|
if (MODE !== "main" || e.button !== 0) return;
|
|
@@ -309,7 +310,7 @@
|
|
|
309
310
|
document.querySelectorAll(".__pin").forEach((p) => p.remove());
|
|
310
311
|
for (const p of pins) {
|
|
311
312
|
const sec = byEid(p.slide.eid);
|
|
312
|
-
if (!sec || sec.
|
|
313
|
+
if (!sec || !sec.matches(SLIDE_OF)) continue;
|
|
313
314
|
let x = p.point.x, y = p.point.y;
|
|
314
315
|
const el = p.eid !== p.slide.eid ? byEid(p.eid, sec) : null;
|
|
315
316
|
if (el && el.getClientRects().length) {
|
|
@@ -328,8 +329,8 @@
|
|
|
328
329
|
function highlight(eid) {
|
|
329
330
|
const el = byEid(eid);
|
|
330
331
|
if (!el) return;
|
|
331
|
-
const sec = el.
|
|
332
|
-
if (sec !==
|
|
332
|
+
const sec = el.closest(SLIDE_OF);
|
|
333
|
+
if (sec !== deck.current()) return;
|
|
333
334
|
const b = boxOf(el, sec);
|
|
334
335
|
const f = document.createElement("div");
|
|
335
336
|
f.className = "__flash";
|
|
@@ -396,8 +397,8 @@
|
|
|
396
397
|
}
|
|
397
398
|
function selectEid(eid) {
|
|
398
399
|
const el = eid && byEid(eid);
|
|
399
|
-
const sec = el?.closest(
|
|
400
|
-
if (!el || sec !==
|
|
400
|
+
const sec = el?.closest(SLIDE_OF);
|
|
401
|
+
if (!el || sec !== deck.current()) return select(null, null);
|
|
401
402
|
select(el, sec);
|
|
402
403
|
}
|
|
403
404
|
|
|
@@ -698,8 +699,8 @@
|
|
|
698
699
|
const h = document.createElement("div");
|
|
699
700
|
h.className = "__edit-hint";
|
|
700
701
|
h.textContent = "Editing · Enter to save · Esc to cancel · Shift+Enter new line";
|
|
701
|
-
Object.assign(h.style, b.y < 30 ? { left: b.x + "px", top: `calc(${b.y + b.h}px + 6px*var(--u,1))` }
|
|
702
|
-
: { left: b.x + "px", top: `calc(${b.y}px - 22px*var(--u,1))` });
|
|
702
|
+
Object.assign(h.style, b.y < 30 ? { left: b.x + "px", top: `calc(${b.y + b.h}px + 6px*var(--pindeck-u,1))` }
|
|
703
|
+
: { left: b.x + "px", top: `calc(${b.y}px - 22px*var(--pindeck-u,1))` });
|
|
703
704
|
editing.sec.appendChild(h);
|
|
704
705
|
}
|
|
705
706
|
function onEditKey(e) {
|
|
@@ -783,7 +784,7 @@
|
|
|
783
784
|
|
|
784
785
|
/* A free id for a new bullet on the same slide ("summary/li-4"); the server double-checks. */
|
|
785
786
|
function newBulletEid(el) {
|
|
786
|
-
const prefix = el.closest(
|
|
787
|
+
const prefix = el.closest(SLIDE_OF).dataset.eid;
|
|
787
788
|
const used = new Set([...document.querySelectorAll("[data-eid]")].map((e) => e.dataset.eid));
|
|
788
789
|
let id = `${prefix}/li`, k = 2;
|
|
789
790
|
while (used.has(id)) id = `${prefix}/li-${k++}`;
|
|
@@ -843,18 +844,183 @@
|
|
|
843
844
|
})));
|
|
844
845
|
}
|
|
845
846
|
|
|
847
|
+
/* ---- Decks -----------------------------------------------------------------------------------------
|
|
848
|
+
What the editor needs from a deck: its slides, the current one, going to a slide, the slide size and
|
|
849
|
+
scale, and events when the slide or the scale changes. reveal.js provides all of it. */
|
|
850
|
+
function revealDeck() {
|
|
851
|
+
const ix = (index) => { const sec = Reveal.getSlides()[index]; return sec ? Reveal.getIndices(sec) : { h: 0, v: 0 }; };
|
|
852
|
+
return {
|
|
853
|
+
slides: () => Reveal.getSlides(),
|
|
854
|
+
current: () => Reveal.getCurrentSlide(),
|
|
855
|
+
goto(index) { if (Reveal.getSlides()[index]) { const i = ix(index); Reveal.slide(i.h, i.v); } },
|
|
856
|
+
hash(index) { const i = ix(index); return `#/${i.h}${i.v ? "/" + i.v : ""}`; },
|
|
857
|
+
size: () => ({ width: Reveal.getConfig().width, height: Reveal.getConfig().height }),
|
|
858
|
+
scale: () => Reveal.getScale() || 1,
|
|
859
|
+
on: (ev, fn) => Reveal.on(ev === "slide" ? "slidechanged" : ev, fn),
|
|
860
|
+
prepare() { // no transitions, every build step shown; the measure and headless copies fill their frame
|
|
861
|
+
const base = { transition: "none", backgroundTransition: "none", fragments: false };
|
|
862
|
+
if (MODE === "main") Reveal.configure({ ...base });
|
|
863
|
+
else Reveal.configure({ ...base, margin: 0, keyboard: false });
|
|
864
|
+
},
|
|
865
|
+
viewport(width, height) { // lay the deck out for this size whatever the window's
|
|
866
|
+
Object.assign(Reveal.getRevealElement().style, { width: width + "px", height: height + "px" });
|
|
867
|
+
Reveal.layout();
|
|
868
|
+
},
|
|
869
|
+
ready: (fn) => (Reveal.isReady() ? fn() : Reveal.on("ready", fn)),
|
|
870
|
+
keys() {}, // reveal.js's own keyboard handling stays
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* A deck made without reveal.js, whose slides <meta name="pindeck:slides"> names (and its size
|
|
875
|
+
<meta name="pindeck:size">). PinDeck shows the slides itself, one at a time and scaled to the frame,
|
|
876
|
+
over whatever the deck's own script does: the elements around the slides lose their transforms, a
|
|
877
|
+
backdrop covers the rest of the page, and the deck's script gets no keys while the editor has them.
|
|
878
|
+
The classes and styles added here live in the page only; the deck file is never written from the DOM. */
|
|
879
|
+
function selectorDeck() {
|
|
880
|
+
const root = document.documentElement;
|
|
881
|
+
const found = () => { const all = [...document.querySelectorAll(SLIDE_SEL)]; return all.filter((s) => !all.some((o) => o !== s && s.contains(o))); };
|
|
882
|
+
let list = [], cur = 0, scale = 1, sz = null, view = null;
|
|
883
|
+
const listeners = { slide: [], resize: [] };
|
|
884
|
+
const emit = (ev) => listeners[ev].forEach((fn) => fn());
|
|
885
|
+
function mark() { // the slides, and the elements between them and <body>
|
|
886
|
+
list = found();
|
|
887
|
+
for (const s of list) {
|
|
888
|
+
s.classList.add("__pindeck_slide");
|
|
889
|
+
for (let a = s.parentElement; a && a !== document.body && a !== root; a = a.parentElement) a.classList.add("__pindeck_anc");
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function size() { // the first call also finds the slides
|
|
893
|
+
if (sz) return sz;
|
|
894
|
+
mark();
|
|
895
|
+
// the server refuses a deck without a readable size, so the fallback is only for a tag removed meanwhile
|
|
896
|
+
const m = /^(\d{2,5})\s*[x×*,\s]\s*(\d{2,5})$/i.exec(deckMeta("size") || "");
|
|
897
|
+
sz = m ? { width: +m[1], height: +m[2] } : { width: 1280, height: 720 };
|
|
898
|
+
return sz;
|
|
899
|
+
}
|
|
900
|
+
// what takes a slide and its containers out of the deck's own layout (scaling, stacking, page views)
|
|
901
|
+
const LOOSE = "transform:none !important; translate:none !important; scale:none !important; rotate:none !important; " +
|
|
902
|
+
"zoom:1 !important; filter:none !important; backdrop-filter:none !important; perspective:none !important; " +
|
|
903
|
+
"contain:none !important; will-change:auto !important; opacity:1 !important; isolation:auto !important; " +
|
|
904
|
+
"mix-blend-mode:normal !important; clip-path:none !important; transition:none !important;";
|
|
905
|
+
function addStyle(text) { const st = document.createElement("style"); st.textContent = text; document.head.appendChild(st); }
|
|
906
|
+
function fit() {
|
|
907
|
+
const { width, height } = size(), m = MODE === "main" ? 0.04 : 0; // the margin reveal.js leaves in the editor
|
|
908
|
+
const vw = view?.width ?? innerWidth, vh = view?.height ?? innerHeight;
|
|
909
|
+
scale = Math.min(vw * (1 - m) / width, vh * (1 - m) / height) || 1;
|
|
910
|
+
root.style.setProperty("--pindeck-s", String(scale));
|
|
911
|
+
root.style.setProperty("--pindeck-x", `${(vw - width * scale) / 2}px`);
|
|
912
|
+
root.style.setProperty("--pindeck-y", `${(vh - height * scale) / 2}px`);
|
|
913
|
+
}
|
|
914
|
+
// a slide the deck's own CSS hides with display: none is shown as a block
|
|
915
|
+
function display(s) { s.classList.remove("__pindeck_block"); if (getComputedStyle(s).display === "none") s.classList.add("__pindeck_block"); }
|
|
916
|
+
function show(i) {
|
|
917
|
+
const s = list[i];
|
|
918
|
+
if (!s) return false;
|
|
919
|
+
const changed = i !== cur;
|
|
920
|
+
list[cur]?.classList.remove("__pindeck_cur");
|
|
921
|
+
cur = i;
|
|
922
|
+
s.classList.add("__pindeck_cur");
|
|
923
|
+
s.removeAttribute("inert"); s.removeAttribute("hidden"); // the deck's script marks the slides it does not show
|
|
924
|
+
display(s);
|
|
925
|
+
return changed;
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
slides: () => list,
|
|
929
|
+
current: () => list[cur],
|
|
930
|
+
goto(index) { if (show(index)) emit("slide"); },
|
|
931
|
+
hash(index) { const s = list[index]; return s?.id ? `#${encodeURIComponent(s.id)}` : `#${index + 1}`; },
|
|
932
|
+
size,
|
|
933
|
+
scale: () => scale,
|
|
934
|
+
on: (ev, fn) => listeners[ev]?.push(fn),
|
|
935
|
+
prepare() {
|
|
936
|
+
const { width, height } = size();
|
|
937
|
+
addStyle(`
|
|
938
|
+
html.__pindeck_deck, html.__pindeck_deck body { overflow:hidden !important; }
|
|
939
|
+
html.__pindeck_deck .__pindeck_anc { ${LOOSE} position:static !important; z-index:auto !important; }
|
|
940
|
+
html.__pindeck_deck .__pindeck_slide:not(.__pindeck_cur) { visibility:hidden !important; }
|
|
941
|
+
html.__pindeck_deck .__pindeck_slide.__pindeck_cur { ${LOOSE} position:fixed !important; inset:0 auto auto 0 !important;
|
|
942
|
+
margin:0 !important; width:${width}px !important; height:${height}px !important; min-width:0 !important; min-height:0 !important;
|
|
943
|
+
max-width:none !important; max-height:none !important; transform-origin:0 0 !important;
|
|
944
|
+
transform:translate(var(--pindeck-x, 0px), var(--pindeck-y, 0px)) scale(var(--pindeck-s, 1)) !important;
|
|
945
|
+
visibility:visible !important; z-index:2147483001 !important; }
|
|
946
|
+
html.__pindeck_deck .__pindeck_slide.__pindeck_block { display:block !important; }
|
|
947
|
+
html.__pindeck_deck .__pindeck_backdrop { position:fixed; inset:0; z-index:2147483000; background:var(--pindeck-bg, #fff); }
|
|
948
|
+
html.__pindeck_deck aside.notes { display:none !important; }`);
|
|
949
|
+
// the backdrop has the page's own background
|
|
950
|
+
const bg = [document.body, root].map((e) => getComputedStyle(e).backgroundColor).find((c) => !/^(transparent|rgba\(.*,\s*0\))$/.test(c));
|
|
951
|
+
if (bg) root.style.setProperty("--pindeck-bg", bg);
|
|
952
|
+
const backdrop = document.createElement("div");
|
|
953
|
+
backdrop.className = "__pindeck_backdrop";
|
|
954
|
+
document.body.appendChild(backdrop);
|
|
955
|
+
root.classList.add("__pindeck_deck");
|
|
956
|
+
fit();
|
|
957
|
+
show(cur);
|
|
958
|
+
addEventListener("resize", () => { fit(); emit("resize"); });
|
|
959
|
+
// the deck's script may hide the slide again (when it changes slides itself)
|
|
960
|
+
new MutationObserver((recs) => { if (recs.some((r) => r.target === list[cur])) { list[cur].removeAttribute("inert"); list[cur].removeAttribute("hidden"); display(list[cur]); } })
|
|
961
|
+
.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["inert", "hidden", "style"] });
|
|
962
|
+
},
|
|
963
|
+
viewport(width, height) { view = { width, height }; fit(); }, // lay the slide out for this size whatever the window's
|
|
964
|
+
ready: (fn) => (document.readyState === "complete" ? fn() : addEventListener("load", () => fn(), { once: true })),
|
|
965
|
+
keys() { // after the editor's own key handler: slide keys change the slide here, and the deck's script gets none
|
|
966
|
+
const field = (t) => t.closest?.("input, textarea, select, [contenteditable]");
|
|
967
|
+
const STEP = { ArrowRight: 1, ArrowDown: 1, PageDown: 1, ArrowLeft: -1, ArrowUp: -1, PageUp: -1 };
|
|
968
|
+
addEventListener("keydown", (e) => {
|
|
969
|
+
if (field(e.target)) return;
|
|
970
|
+
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
971
|
+
const k = e.key, step = k === " " ? (e.shiftKey ? -1 : 1) : STEP[k];
|
|
972
|
+
const to = step ? cur + step : k === "Home" ? 0 : k === "End" ? list.length - 1 : null;
|
|
973
|
+
if (to != null) { e.preventDefault(); this.goto(Math.max(0, Math.min(list.length - 1, to))); }
|
|
974
|
+
}
|
|
975
|
+
e.stopImmediatePropagation();
|
|
976
|
+
}, true);
|
|
977
|
+
for (const t of ["keyup", "keypress"]) addEventListener(t, (e) => { if (!field(e.target)) e.stopImmediatePropagation(); }, true);
|
|
978
|
+
},
|
|
979
|
+
/* ?editor=pdf: every slide as a page of its own size, for PinDeck's PDF export (lib/pdf.mjs prints
|
|
980
|
+
this page's DOM as it is once laid out, so the layout is all CSS) */
|
|
981
|
+
print() {
|
|
982
|
+
const { width, height } = size();
|
|
983
|
+
const all = params.get("showHiddenSlides") === "true";
|
|
984
|
+
const shown = list.filter((s) => all || s.getAttribute("data-visibility") !== "hidden");
|
|
985
|
+
for (const s of list) { s.classList.add("pdf-page"); if (!shown.includes(s)) s.classList.add("__pindeck_skip"); }
|
|
986
|
+
shown.at(-1)?.classList.add("__pindeck_last");
|
|
987
|
+
const OUT = ":not(.__pindeck_anc):not(.__pindeck_slide)";
|
|
988
|
+
addStyle(`
|
|
989
|
+
@page { size:${width}px ${height}px; margin:0; }
|
|
990
|
+
html.__pindeck_print, html.__pindeck_print body { margin:0 !important; padding:0 !important; width:auto !important;
|
|
991
|
+
height:auto !important; overflow:visible !important; }
|
|
992
|
+
html.__pindeck_print .__pindeck_anc { ${LOOSE} position:static !important; display:block !important; width:auto !important;
|
|
993
|
+
height:auto !important; margin:0 !important; padding:0 !important; border:0 !important; overflow:visible !important; }
|
|
994
|
+
html.__pindeck_print body > ${OUT}:not(svg), html.__pindeck_print .__pindeck_anc > ${OUT}:not(svg) { display:none !important; }
|
|
995
|
+
html.__pindeck_print body > svg${OUT}, html.__pindeck_print .__pindeck_anc > svg${OUT} { position:absolute !important;
|
|
996
|
+
width:0 !important; height:0 !important; overflow:hidden !important; }
|
|
997
|
+
html.__pindeck_print .__pindeck_slide { ${LOOSE} position:relative !important; inset:auto !important; margin:0 !important;
|
|
998
|
+
width:${width}px !important; height:${height}px !important; max-width:none !important; max-height:none !important;
|
|
999
|
+
visibility:visible !important; overflow:hidden !important; break-inside:avoid; break-after:page; page-break-after:always; }
|
|
1000
|
+
html.__pindeck_print .__pindeck_slide.__pindeck_last { break-after:auto; page-break-after:auto; }
|
|
1001
|
+
html.__pindeck_print .__pindeck_slide.__pindeck_block { display:block !important; }
|
|
1002
|
+
html.__pindeck_print .__pindeck_skip { display:none !important; }
|
|
1003
|
+
html.__pindeck_print aside.notes { display:none !important; }`);
|
|
1004
|
+
root.classList.add("__pindeck_print");
|
|
1005
|
+
shown.forEach(display);
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
846
1010
|
/* ---- Start ------------------------------------------------------------------------------------- */
|
|
847
1011
|
async function start() {
|
|
848
|
-
W
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if (MODE === "main") Reveal.configure({ ...base });
|
|
853
|
-
else Reveal.configure({ ...base, margin: 0, keyboard: false });
|
|
1012
|
+
({ width: W, height: H } = deck.size());
|
|
1013
|
+
const setUnit = () => document.documentElement.style.setProperty("--pindeck-u", String(1 / (deck.scale() || 1)));
|
|
1014
|
+
deck.on("resize", setUnit);
|
|
1015
|
+
deck.prepare();
|
|
854
1016
|
setUnit();
|
|
855
1017
|
await document.fonts.ready;
|
|
856
1018
|
|
|
857
1019
|
if (MODE === "shot" || MODE === "dump") {
|
|
1020
|
+
// the size of headless Chrome's window, which the page itself gets only once the screenshot is
|
|
1021
|
+
// taken: lay the deck out for it now rather than letterboxed into the shorter viewport
|
|
1022
|
+
const vw = +params.get("width"), vh = +params.get("height");
|
|
1023
|
+
if (vw > 0 && vh > 0) { deck.viewport(vw, vh); setUnit(); }
|
|
858
1024
|
const sec = byEid(params.get("slide") || "") || slides()[0];
|
|
859
1025
|
goto(indexOf(sec));
|
|
860
1026
|
await nextFrame();
|
|
@@ -872,11 +1038,11 @@
|
|
|
872
1038
|
}
|
|
873
1039
|
|
|
874
1040
|
const api = {
|
|
875
|
-
info, goto,
|
|
1041
|
+
info, goto, presentHash, highlight, reloadCss,
|
|
876
1042
|
setMode, selectEid, nudge, editSelection,
|
|
877
1043
|
// a new slide: select its title and open it for typing
|
|
878
1044
|
editTitle() {
|
|
879
|
-
const sec =
|
|
1045
|
+
const sec = deck.current();
|
|
880
1046
|
const h = sec?.querySelector("h1[data-eid], h2[data-eid], h3[data-eid]");
|
|
881
1047
|
if (!h || mode !== "select") return;
|
|
882
1048
|
select(h, sec);
|
|
@@ -942,22 +1108,35 @@
|
|
|
942
1108
|
host()?.key(k, { meta, shift: e.shiftKey });
|
|
943
1109
|
}
|
|
944
1110
|
}, true);
|
|
945
|
-
|
|
946
|
-
|
|
1111
|
+
deck.on("slide", () => {
|
|
1112
|
+
if (editing) finishEdit(true); hideHover(); if (sel) select(null, null);
|
|
1113
|
+
// a deck made without reveal.js lays out only the current slide at the slide size: pins on the
|
|
1114
|
+
// others were placed in another layout
|
|
1115
|
+
if (SLIDE_SEL) renderPins(pins);
|
|
1116
|
+
host()?.slideChanged(indexOf(deck.current()));
|
|
1117
|
+
});
|
|
1118
|
+
deck.on("resize", () => { if (sel) drawSel(); });
|
|
947
1119
|
setMode("select");
|
|
948
1120
|
// a banner on hidden slides, so nobody edits one without noticing (only in the editor view)
|
|
949
1121
|
for (const sec of slides()) {
|
|
950
1122
|
if (sec.getAttribute("data-visibility") !== "hidden") continue;
|
|
951
1123
|
const b = document.createElement("div");
|
|
952
1124
|
b.className = "__hidden-banner";
|
|
953
|
-
b.textContent = "Hidden slide · skipped when presenting and in PDFs";
|
|
1125
|
+
b.textContent = SLIDE_SEL ? "Hidden slide · skipped in PDFs" : "Hidden slide · skipped when presenting and in PDFs";
|
|
954
1126
|
sec.appendChild(b);
|
|
955
1127
|
}
|
|
956
1128
|
const startAt = +params.get("start");
|
|
957
1129
|
if (startAt) goto(startAt);
|
|
1130
|
+
deck.keys();
|
|
958
1131
|
}
|
|
959
1132
|
host()?.bridgeReady(MODE, api, window);
|
|
960
1133
|
}
|
|
961
|
-
if (
|
|
962
|
-
|
|
1134
|
+
if (SLIDE_SEL) {
|
|
1135
|
+
deck = selectorDeck();
|
|
1136
|
+
deck.ready(() => (MODE === "pdf" ? deck.print() : start()));
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (typeof Reveal === "undefined" || MODE === "pdf") return; // reveal.js lays out its own print view
|
|
1140
|
+
deck = revealDeck();
|
|
1141
|
+
deck.ready(start);
|
|
963
1142
|
})();
|
package/public/editor.js
CHANGED
|
@@ -229,7 +229,8 @@ function renderSlides() {
|
|
|
229
229
|
${s.kind !== "content" ? `<span class="kind">${esc(s.kind.replace(/-slide$/, ""))}</span>` : ""}
|
|
230
230
|
${s.hidden ? '<span class="kind hid">hidden</span>' : ""}</span>
|
|
231
231
|
${counts[i] ? `<span class="badge" title="${counts[i]} pin(s)">${counts[i]}</span>` : "<span></span>"}
|
|
232
|
-
<button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" :
|
|
232
|
+
<button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : state.status?.slideSelector ?
|
|
233
|
+
"Hide this slide (left out of PDFs)" : "Hide this slide (skipped when presenting)"}"
|
|
233
234
|
aria-label="${s.hidden ? "Show" : "Hide"} slide ${i + 1}">${s.hidden ? EYE_ICON : EYE_OFF_ICON}</button>
|
|
234
235
|
</li>`).join("");
|
|
235
236
|
$("slide-list").querySelector("li.active")?.scrollIntoView({ block: "nearest" });
|
|
@@ -492,8 +493,7 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
492
493
|
});
|
|
493
494
|
|
|
494
495
|
$("present").onclick = () => {
|
|
495
|
-
|
|
496
|
-
window.open(`/deck/#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
|
|
496
|
+
window.open(`/deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
|
|
497
497
|
};
|
|
498
498
|
|
|
499
499
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -739,6 +739,7 @@ document.addEventListener("keydown", (e) => {
|
|
|
739
739
|
});
|
|
740
740
|
|
|
741
741
|
/* ---- Server events ---------------------------------------------------------------------------------- */
|
|
742
|
+
let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
|
|
742
743
|
function applyStatus(s) {
|
|
743
744
|
state.status = s;
|
|
744
745
|
// the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
|
|
@@ -785,6 +786,9 @@ function applyStatus(s) {
|
|
|
785
786
|
$("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
|
|
786
787
|
$("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
|
|
787
788
|
$("add-slide-btn").disabled = !!s.busy;
|
|
789
|
+
// the deck's slide tags stopped working while PinDeck runs (at start, such a deck is refused)
|
|
790
|
+
if (s.deckProblem && s.deckProblem !== shownProblem) toast(s.deckProblem, { ms: 20000 });
|
|
791
|
+
shownProblem = s.deckProblem;
|
|
788
792
|
renderToolbar();
|
|
789
793
|
}
|
|
790
794
|
function connect() {
|
package/server.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import path from "node:path";
|
|
|
24
24
|
import crypto from "node:crypto";
|
|
25
25
|
import { spawn, execFile } from "node:child_process";
|
|
26
26
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
-
import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
27
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
28
28
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
29
29
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
30
30
|
import { CodexSession } from "./lib/codex.mjs";
|
|
@@ -45,7 +45,8 @@ const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--pr
|
|
|
45
45
|
pindeck login [deck-folder | deck-file.html]
|
|
46
46
|
|
|
47
47
|
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
48
|
-
folder), which then opens its index.html.
|
|
48
|
+
folder), which then opens its index.html. A deck made otherwise names its slides in its <head> with
|
|
49
|
+
<meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
|
|
49
50
|
login prints a new one-time login link for the PinDeck running for that deck
|
|
50
51
|
(without a deck: for the PinDeck started last).
|
|
51
52
|
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
@@ -126,8 +127,9 @@ if (deckProblem) {
|
|
|
126
127
|
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
127
128
|
process.exit(1);
|
|
128
129
|
}
|
|
129
|
-
//
|
|
130
|
-
const
|
|
130
|
+
// reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
|
|
131
|
+
const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
|
|
132
|
+
const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
|
|
131
133
|
if (frameworkProblem) {
|
|
132
134
|
console.error(frameworkProblem);
|
|
133
135
|
process.exit(1);
|
|
@@ -159,6 +161,22 @@ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
|
|
|
159
161
|
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
|
|
160
162
|
const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
|
|
161
163
|
const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
|
|
164
|
+
/* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
|
|
165
|
+
their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
|
|
166
|
+
the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
|
|
167
|
+
screenshot is the slide itself. */
|
|
168
|
+
function deckSlides() {
|
|
169
|
+
const tree = parse(readDeck(DECK_FILE) || "");
|
|
170
|
+
const selector = slideSelector(tree);
|
|
171
|
+
return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
|
|
172
|
+
}
|
|
173
|
+
/* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
|
|
174
|
+
start-up check's message, which the editor shows and Claude is told */
|
|
175
|
+
function deckProblemNow() {
|
|
176
|
+
const html = readDeck(DECK_FILE);
|
|
177
|
+
return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
|
|
178
|
+
}
|
|
179
|
+
let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
|
|
162
180
|
const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
|
|
163
181
|
function writeDeck(f, content, manual = false) {
|
|
164
182
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
@@ -283,15 +301,25 @@ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (
|
|
|
283
301
|
/* Headless Chrome on macOS often lingers long after it has written its output, so we
|
|
284
302
|
watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
|
|
285
303
|
let chromeSeq = 0;
|
|
304
|
+
/* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
|
|
305
|
+
a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
|
|
306
|
+
to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
|
|
307
|
+
function sized(url, size) {
|
|
308
|
+
const u = new URL(url);
|
|
309
|
+
u.searchParams.set("width", size.width);
|
|
310
|
+
u.searchParams.set("height", size.height);
|
|
311
|
+
return u.href;
|
|
312
|
+
}
|
|
286
313
|
function chrome(args, done) {
|
|
287
314
|
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
288
315
|
return new Promise((resolve, reject) => {
|
|
289
316
|
// a profile per run: concurrent runs must not share one
|
|
290
317
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
318
|
+
const { size } = deckSlides();
|
|
291
319
|
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
292
320
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
293
|
-
|
|
294
|
-
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
|
|
321
|
+
`--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
322
|
+
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
|
|
295
323
|
let out = "", err = "", finished = false;
|
|
296
324
|
const finish = (fn, v) => {
|
|
297
325
|
if (finished) return;
|
|
@@ -467,7 +495,8 @@ let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = n
|
|
|
467
495
|
let session = null, starting = null;
|
|
468
496
|
const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
469
497
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
470
|
-
chrome: CAPS.screenshot.available,
|
|
498
|
+
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
499
|
+
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
471
500
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
472
501
|
const pushStatus = () => broadcast("status", status());
|
|
473
502
|
|
|
@@ -479,6 +508,15 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
|
|
|
479
508
|
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
480
509
|
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
481
510
|
`other decks may use them too.\n` : "");
|
|
511
|
+
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
512
|
+
function slidesInstructions() {
|
|
513
|
+
const { selector } = deckSlides();
|
|
514
|
+
return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
|
|
515
|
+
`CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
|
|
516
|
+
`instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
|
|
517
|
+
`a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
|
|
518
|
+
`left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
|
|
519
|
+
}
|
|
482
520
|
/* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
|
|
483
521
|
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
484
522
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
@@ -515,7 +553,7 @@ When context mentions Read, use pindeck_read. Read screenshots with pindeck_read
|
|
|
515
553
|
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
516
554
|
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
517
555
|
|
|
518
|
-
## Hand edits`) + deckInstructions();
|
|
556
|
+
## Hand edits`) + slidesInstructions() + deckInstructions();
|
|
519
557
|
function makeSession(choice) {
|
|
520
558
|
let initial = null;
|
|
521
559
|
const options = {
|
|
@@ -535,7 +573,7 @@ function makeSession(choice) {
|
|
|
535
573
|
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
536
574
|
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
537
575
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
538
|
-
args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
|
|
576
|
+
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
539
577
|
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
540
578
|
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
541
579
|
"--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
|
|
@@ -867,6 +905,14 @@ function scheduleFlush() {
|
|
|
867
905
|
};
|
|
868
906
|
eidTimer = setTimeout(pass, 800);
|
|
869
907
|
}
|
|
908
|
+
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
909
|
+
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
910
|
+
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
911
|
+
if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
|
|
912
|
+
deckNow = now;
|
|
913
|
+
pushStatus();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
870
916
|
// manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
|
|
871
917
|
broadcast("files", { files, manual, rev: deckRev() });
|
|
872
918
|
}, 120);
|
|
@@ -1254,7 +1300,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1254
1300
|
return serveFile(res, f, (s) => {
|
|
1255
1301
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1256
1302
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
1257
|
-
if (mode === "pdf")
|
|
1303
|
+
if (mode === "pdf") {
|
|
1304
|
+
s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
|
|
1305
|
+
// a deck that names its slides (in the file as it is now) is laid out by the bridge; reveal.js lays out its own
|
|
1306
|
+
return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
|
|
1307
|
+
}
|
|
1258
1308
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1259
1309
|
});
|
|
1260
1310
|
}
|
|
@@ -1371,14 +1421,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1371
1421
|
const b = await body(req);
|
|
1372
1422
|
if (staleEdit(res, b)) return;
|
|
1373
1423
|
const before = snapshot(EDITABLE);
|
|
1374
|
-
const
|
|
1424
|
+
const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
|
|
1375
1425
|
const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
|
|
1376
1426
|
if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
|
|
1377
1427
|
const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
|
|
1378
1428
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1429
|
+
const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
|
|
1430
|
+
: "skipped when presenting and in PDF exports";
|
|
1379
1431
|
const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
|
|
1380
1432
|
`The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
|
|
1381
|
-
`data-visibility="hidden": they stay in the file but are skipped
|
|
1433
|
+
`data-visibility="hidden": they stay in the file but are ${skipped}.`,
|
|
1382
1434
|
{ manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
|
|
1383
1435
|
return json(res, 200, { turnId, index });
|
|
1384
1436
|
}
|
|
@@ -1624,11 +1676,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1624
1676
|
return text(res, r.status || 200, r.text);
|
|
1625
1677
|
}
|
|
1626
1678
|
if (p === "/api/slides") {
|
|
1627
|
-
|
|
1679
|
+
const list = slideList(), why = !list.length && deckProblemNow();
|
|
1680
|
+
if (why) return text(res, 409, why);
|
|
1681
|
+
return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
|
|
1628
1682
|
}
|
|
1629
1683
|
if (p === "/api/layout" || p === "/api/shot") {
|
|
1630
1684
|
const slide = resolveSlide(url.searchParams.get("slide"));
|
|
1631
|
-
if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1685
|
+
if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1632
1686
|
if (!slide.eid) runEidPass();
|
|
1633
1687
|
const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
|
|
1634
1688
|
if (p === "/api/shot") {
|