fb-slides 0.5.1 → 0.6.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/CHANGELOG.md ADDED
@@ -0,0 +1,118 @@
1
+ # Changelog
2
+
3
+ What changed in each release, newest first. A deck picks the engine up from the
4
+ package, so `npm update fb-slides` is how a talk written months ago gets all of
5
+ this — which makes it worth being able to see what arrived.
6
+
7
+ Entries are written as the work lands, under **Unreleased**; the release commit
8
+ that stamps the version renames that heading to the version and its date.
9
+
10
+ ## 0.6.3 — 2026-09-05
11
+
12
+ - **Disabled slides.** `<!-- disabled -->` on a line of its own keeps a slide in
13
+ the file without keeping it in the talk: the arrows walk past it, it holds no
14
+ slide number, and the count and the progress bar are computed as if it were
15
+ not there. It is still listed in the navigator and in reveal's overview,
16
+ dimmed and marked *off* — clicking it there opens it, which is the point of
17
+ keeping it.
18
+
19
+ ## 0.6.2 — 2026-09-05
20
+
21
+ - **Fixed:** the toolbar could close itself. One tool arming told the others to
22
+ stand down, and the bar read that handshake as "nothing is armed" while a tool
23
+ still was. The armed state is now recomputed from the buttons themselves.
24
+
25
+ ## 0.6.1 — 2026-09-05
26
+
27
+ - **The toolbar moved to the top edge**, and sleeps just above it: a chevron says
28
+ it is there, and the bar comes down when the pointer goes for it. The slides
29
+ keep the bottom of the screen.
30
+
31
+ ## 0.6.0 — 2026-09-05
32
+
33
+ - **A vocabulary of slide layouts** in the base theme, so a deck stops writing
34
+ `style=""`: `.cols`/`.col` (two columns, with the `min-width: 0` a code block
35
+ falls over without), `.frame` (an embed that shares the slide), `.caption`,
36
+ `.box`, and `.author-slide` — the speaker page, photos down the left edge and
37
+ the bio on the right. Each fixes its numbers in CSS variables, so a project
38
+ retunes one from its own `theme.css`. Images and clips are now centred and
39
+ lose the white card reveal's core CSS frames every image with.
40
+ - **The speaker page's photos cross-fade** for however many there are: the
41
+ keyframes are written at load from the two timing tokens, instead of a fixed
42
+ table that stopped at eight.
43
+ - **A block picker in the edit drawer.** The `+` over the slide box — or `/` on
44
+ an empty line — writes the markup nobody remembers: the demo marker, a framed
45
+ embed, a video with the attributes that make it behave, two columns, a fenced
46
+ block, a mermaid graph, the speaker page. What was selected takes the place of
47
+ the first placeholder, so picking *Fragment* wraps the paragraph you had
48
+ highlighted. `snippets:` in `slides.config.js` adds a project's own.
49
+ - **The drawer takes half the page** on the chevron where its title used to be,
50
+ and stays that way across the reload every save ends in.
51
+ - **Add page / Delete page** say what they act on — the slide, not the text in
52
+ the box.
53
+ - **The starter** comes with the speaker page already written, second, against
54
+ two placeholder photos, and its demos deck now shows a framed embed next to
55
+ the two full-bleed ones.
56
+
57
+ ## 0.5.1 — 2026-09-05
58
+
59
+ - The slide number under the arrows recedes: no pill, thinner ink, smaller type.
60
+
61
+ ## 0.5.0 — 2026-09-05
62
+
63
+ - **The edit drawer.** `T` opens the slide you are looking at down the right
64
+ edge — its markdown in one box, its speaker notes in the other. Typing
65
+ re-renders the real slide in place, and **Save** writes the text back into that
66
+ slide's own lines of the `.md`, leaving the rest of the file alone. **Add** and
67
+ **Delete** work on the file too. Dev server only: a built deck never shows it.
68
+ - Docs: how to make a demo a real application.
69
+
70
+ ## 0.4.3 — 2026-09-05
71
+
72
+ - **Fixed:** the spotlight left a ghost hole behind once its veil had faded.
73
+
74
+ ## 0.4.2 — 2026-09-05
75
+
76
+ - The four tools moved onto the **QWER row**, in toolbar order, and each chip
77
+ wears its own key along its bottom edge.
78
+
79
+ ## 0.4.1 — 2026-09-03
80
+
81
+ - Packaging fixes.
82
+
83
+ ## 0.4.0 — 2026-09-03
84
+
85
+ - **The pointer**: the cursor swapped for a glowing halo, a centre-seeking dart,
86
+ or itself.
87
+ - **The arrow**: click the tail, then the tip — the pen's colours, the pen's
88
+ fade, and a head large enough to read from the back row.
89
+ - The toolbar moved to the bottom centre, its popovers opening upwards.
90
+
91
+ ## 0.3.0 — 2026-09-03
92
+
93
+ - **The pen** holds its ink for two seconds before it fades, and the number keys
94
+ pick its colour.
95
+
96
+ ## 0.2.0 — 2026-09-03
97
+
98
+ - **`revealTheme:`** — wear one of reveal.js's own fifteen themes, served from
99
+ the deck rather than a CDN. The chrome follows it through the theme's own
100
+ variables.
101
+
102
+ ## 0.1.2 — 2026-09-03
103
+
104
+ - A hairline scrollbar for the navigator.
105
+ - `npm run starter` runs the starter template in place.
106
+
107
+ ## 0.1.1 — 2026-09-03
108
+
109
+ - **The vertical navigator** (`V`): the deck as a readable list down the left
110
+ edge, with a list/preview toggle and the talk's own structure as its depth.
111
+ - **The spotlight**, and a shortcuts registry behind the help overlay.
112
+
113
+ ## 0.1.0 — 2026-09-03
114
+
115
+ First release. Markdown files in `decks/` become a reveal.js deck with no build
116
+ step: live demo embeds that run in the slide, mermaid diagrams, stepped code
117
+ highlighting, speaker notes, an annotation pen, a zero-config dev server, and a
118
+ `build` that writes a folder you can publish anywhere.
package/README.md CHANGED
@@ -10,7 +10,7 @@ cd my-talk && npm install && npm run dev
10
10
  ```
11
11
 
12
12
  The engine is the package; a talk is only its own content. Bump the version and every
13
- deck you have written gets the new features.
13
+ deck you have written gets the new features — [CHANGELOG.md](CHANGELOG.md) says which.
14
14
 
15
15
  ## The shape of a talk
16
16
 
@@ -127,6 +127,87 @@ A `Note:` block at the end of a slide never shows on screen, only in the speaker
127
127
  Note: a bad description is the most common reason a tool never gets used.
128
128
  ```
129
129
 
130
+ ### Disabled slides
131
+
132
+ A slide whose source carries `<!-- disabled -->` on a line of its own stays in the
133
+ file and in the navigator, but steps out of the talk:
134
+
135
+ ```markdown
136
+ ---
137
+
138
+ <!-- disabled -->
139
+
140
+ ## The version of this I cut on the train
141
+
142
+ ---
143
+ ```
144
+
145
+ The arrows walk past it in whichever direction they were going, it holds no slide
146
+ number, and the count and the progress bar are computed as if it were not there.
147
+ It is still listed in the navigator (`V`) and in reveal's overview, marked **off** —
148
+ and clicking it there is how you look at it, which is the point of keeping it.
149
+ Looking at one, the corner reads `off` where the number would be.
150
+
151
+ Half a talk is the slides you decided against but are not ready to delete. This is
152
+ where they live, next to the ones that made it.
153
+
154
+ ### Blocks
155
+
156
+ Six classes cover the layouts a talk keeps needing, so a slide asks for one by name
157
+ instead of carrying a paragraph of inline styles:
158
+
159
+ | | |
160
+ | --- | --- |
161
+ | `.cols` / `.col` | two columns — with the `min-width: 0` a code block falls over without |
162
+ | `.frame` | an embed that shares the slide instead of taking it whole |
163
+ | `.caption` | a quiet line under a figure |
164
+ | `.box` | a bordered block: the point the slide comes back to |
165
+ | `.author-slide` | the speaker page — photos down the left edge, the bio on the right |
166
+ | `<img>`, `<video>` | centred, unframed, and never wider than the slide |
167
+
168
+ ````markdown
169
+ <div class="cols">
170
+ <div class="col">
171
+
172
+ **In the widget**
173
+
174
+ ```js
175
+ app.openLink({ url: 'https://example.com/' });
176
+ ```
177
+
178
+ </div>
179
+ <div class="col">
180
+
181
+ The blank lines matter: Markdown inside a block-level tag is only parsed
182
+ when there is one on each side of it.
183
+
184
+ </div>
185
+ </div>
186
+ ````
187
+
188
+ Each of them fixes its numbers in CSS variables — `--cols-gap`, `--frame-w`,
189
+ `--frame-h`, `--author-photo`, `--author-hold` — so a project retunes one from its own
190
+ `theme.css` without restating the rule.
191
+
192
+ The speaker page is markup you write and the theme lays out. Give `.author-photo` as
193
+ many `<img>` as you like: they cross-fade in the order they are written, on a loop that
194
+ restarts every time the slide comes up.
195
+
196
+ ```markdown
197
+ <!-- .slide: class="author-slide" -->
198
+
199
+ <div class="author-photo">
200
+ <img src="assets/author/on-stage.jpg" alt="" />
201
+ <img src="assets/author/portrait.jpg" alt="" />
202
+ </div>
203
+
204
+ <div class="author-bio">
205
+ <h1>Your name</h1>
206
+ <ul><li>What you do</li></ul>
207
+ <p class="author-meta">The stack · you · work · with</p>
208
+ </div>
209
+ ```
210
+
130
211
  ### Editing in the browser
131
212
 
132
213
  On the dev server, `T` (or the last button on the toolbar) opens the slide you
@@ -140,6 +221,22 @@ right after the current one and opens it; **Delete** removes the slide from its
140
221
  file, after asking. Clicking into the notes box trades the room with the slide
141
222
  box, so both are comfortable to write in.
142
223
 
224
+ The `+` over the slide box — or `/` typed on an empty line — opens the block
225
+ picker: the demo marker, a framed embed, a video with the attributes that make
226
+ it behave, two columns, a fenced block, the speaker page. The block lands where
227
+ the cursor was, with its first placeholder selected so it is ready to type over;
228
+ text already selected takes that placeholder's place, so picking **Fragment**
229
+ wraps the paragraph that was highlighted. A block that only works at the top of
230
+ a slide, like the demo marker, goes there whatever the cursor was doing.
231
+
232
+ `snippets:` in `slides.config.js` adds a project's own blocks to the list:
233
+
234
+ ```js
235
+ snippets: [
236
+ { label: 'Pricing table', hint: 'the three tiers', body: '<div class="tiers">${…}</div>' },
237
+ ],
238
+ ```
239
+
143
240
  The drawer exists only under `fb-slides dev`: a built deck is static files and
144
241
  never shows the button.
145
242
 
@@ -194,6 +291,7 @@ export default {
194
291
  outDir: 'dist',
195
292
  exclude: ['dist'], // extra path segments the build skips
196
293
  fragmentLists: true, // bullet lists reveal one item at a time
294
+ snippets: [], // your own blocks in the edit drawer's picker
197
295
  reveal: { transition: 'fade' },// passed to Reveal.initialize()
198
296
  mermaid: {}, // passed to mermaid.initialize()
199
297
  };
package/lib/config.mjs CHANGED
@@ -80,6 +80,10 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
80
80
  reveal: user.reveal ?? {},
81
81
  // Bullet lists of 3+ items appear one item at a time.
82
82
  fragmentLists: user.fragmentLists ?? true,
83
+ // The project's own entries in the edit drawer's block picker, after the
84
+ // built-in ones: `{ label, hint, body, top }`, where `${…}` in the body
85
+ // marks what has to be typed over.
86
+ snippets: user.snippets ?? [],
83
87
  // The build output, at any depth: a framework demo's own dist/ is its dev
84
88
  // server's business, and the deck ships that demo as sources.
85
89
  exclude: [...ALWAYS_EXCLUDED, overrides.outDir ?? user.outDir ?? 'dist', ...(user.exclude ?? [])],
package/lib/render.mjs CHANGED
@@ -46,6 +46,7 @@ export const renderIndex = async (config, runtimeDir) => {
46
46
  demos: config.urls.demos,
47
47
  reveal: config.reveal,
48
48
  fragmentLists: config.fragmentLists,
49
+ snippets: config.snippets,
49
50
  };
50
51
 
51
52
  // The chrome reads this to take its colours from the reveal theme instead of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.5.1",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
6
6
  "keywords": [
@@ -30,6 +30,7 @@
30
30
  "lib",
31
31
  "runtime",
32
32
  "templates",
33
+ "CHANGELOG.md",
33
34
  "README.md",
34
35
  "LICENSE"
35
36
  ],
@@ -477,7 +477,17 @@ const RevealAnnotate = () => ({
477
477
  for (const [name, button] of Object.entries(toolButtons)) {
478
478
  button.setAttribute('aria-pressed', String(name === tool));
479
479
  }
480
- toolbar.classList.toggle('is-armed', tool !== null);
480
+ // `is-armed` is shared with the other tool plugins, and the arming
481
+ // handshake fires mid-switch: the newcomer sets the class, then its
482
+ // event runs this module's stand-down — so a plain toggle with this
483
+ // module's own boolean would wipe it with a tool still armed. It is
484
+ // recomputed from the buttons instead, which every module keeps
485
+ // truthful. The edit chip stays out of it: its pressed state is the
486
+ // drawer, not an armed tool.
487
+ toolbar.classList.toggle(
488
+ 'is-armed',
489
+ !!toolbar.querySelector('.deck-tool[aria-pressed="true"]:not(#tool-edit)'),
490
+ );
481
491
  // The colours are only worth showing while there is a tool to apply them
482
492
  // to, which makes arming one the gesture that opens them.
483
493
  swatches.hidden = tool === null;
package/runtime/deck.js CHANGED
@@ -61,6 +61,13 @@ if (!DECKS.length) console.error(`[deck] no .md found in ${DECKS_DIR}`);
61
61
  const DEMO_MARKER = /^<!--\s*demo:\s*(\S+?)\s*-->/;
62
62
  const DEMOS_DIR = CFG.demos ?? 'demo/';
63
63
 
64
+ // A slide whose source carries `<!-- disabled -->` stays in the file and in the
65
+ // navigator, but steps out of the talk: the arrows walk past it and it is not
66
+ // counted. The line is stripped before rendering — what is left is an ordinary
67
+ // slide, demo marker included, for the day it is turned back on.
68
+ const DISABLED_MARKER = /^[ \t]*<!--[ \t]*disabled[ \t]*-->[ \t]*\r?\n?/im;
69
+ const OFF = 'slide-off';
70
+
64
71
  // Bullet lists reveal themselves one item at a time. `?nofrag` turns it off for
65
72
  // one visit; `fragmentLists: false` in the config turns it off for the project.
66
73
  const AUTO_FRAGMENT = CFG.fragmentLists !== false && !new URLSearchParams(location.search).has('nofrag');
@@ -135,9 +142,18 @@ for (const file of DECKS) {
135
142
  // `section:` names the deck; without one the file name has to do.
136
143
  const deck = { file, label: front.section ?? file.replace(/^\d+-|\.md$/g, '') };
137
144
 
138
- for (const [index, source] of splitSlides(stripFrontMatter(markdown)).entries()) {
145
+ for (const [index, raw] of splitSlides(stripFrontMatter(markdown)).entries()) {
146
+ const disabled = DISABLED_MARKER.test(raw);
147
+ const source = disabled ? raw.replace(DISABLED_MARKER, '') : raw;
139
148
  const marker = source.match(DEMO_MARKER);
140
149
  const section = marker ? demoSection(marker[1], deck) : markdownSection(source, deck);
150
+ if (disabled) {
151
+ section.classList.add(OFF);
152
+ // Reveal's own answer to "in the deck, out of the count": it leaves an
153
+ // uncounted slide out of `getSlides()`, the slide number and the progress
154
+ // bar, while keeping it reachable by index.
155
+ section.dataset.visibility = 'uncounted';
156
+ }
141
157
  // Where the slide came from — the file and its index within it, counted by
142
158
  // the same split as above. The edit drawer saves back through these; the
143
159
  // markdown plugin forwards data-* attributes when it rewrites the section.
@@ -197,6 +213,54 @@ const nudge = (slide) => {
197
213
  Reveal.on('ready', ({ currentSlide }) => nudge(currentSlide));
198
214
  Reveal.on('slidechanged', ({ currentSlide }) => nudge(currentSlide));
199
215
 
216
+ // ---------------------------------------------------------------------------
217
+ // Walking past the disabled slides. Reveal already leaves them out of the
218
+ // count; the talk itself steps over them here, in whichever direction it was
219
+ // already going. Two ways in are deliberate and let through: picking a slide by
220
+ // hand — the navigator, reveal's overview — and landing on one at load, which
221
+ // is what the edit drawer does on every save.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ const sections = () => [...slidesEl.querySelectorAll(':scope > section')];
225
+
226
+ let jumping = false;
227
+ // Cleared on the next turn of the loop: `slidechanged` fires inside the call
228
+ // that follows, so a click that lands on the slide already showing — no event
229
+ // at all — cannot leave the flag standing for the next press of an arrow.
230
+ const jump = () => {
231
+ jumping = true;
232
+ setTimeout(() => {
233
+ jumping = false;
234
+ }, 0);
235
+ };
236
+ document.addEventListener('deck:jump', jump);
237
+ slidesEl.addEventListener('click', () => Reveal.isOverview() && jump(), true);
238
+
239
+ let cameFrom = Reveal.getIndices().h;
240
+
241
+ Reveal.on('slidechanged', ({ indexh, currentSlide }) => {
242
+ const deliberate = jumping;
243
+ jumping = false;
244
+ if (deliberate || !currentSlide?.classList.contains(OFF)) {
245
+ cameFrom = indexh;
246
+ return;
247
+ }
248
+
249
+ const slides = sections();
250
+ const step = indexh < cameFrom ? -1 : 1;
251
+ const seek = (start, direction) => {
252
+ let i = start;
253
+ while (slides[i]?.classList.contains(OFF)) i += direction;
254
+ return slides[i] ? i : null;
255
+ };
256
+
257
+ // Off the end — the deck ends on disabled slides — turn round rather than
258
+ // strand the talk on one it is not meant to show.
259
+ const target = seek(indexh + step, step) ?? seek(indexh - step, -step);
260
+ if (target === null) cameFrom = indexh; // every slide is disabled: stay put
261
+ else Reveal.slide(target);
262
+ });
263
+
200
264
  // ---------------------------------------------------------------------------
201
265
  // Post-processing — everything below runs on markdown the plugin has rendered.
202
266
  // ---------------------------------------------------------------------------
@@ -294,6 +358,58 @@ for (const pre of document.querySelectorAll('.slides pre')) {
294
358
  if (lines > 14) pre.classList.add('pre--dense');
295
359
  }
296
360
 
361
+ // ---------------------------------------------------------------------------
362
+ // The speaker page's photos cross-fade in DOM order (`.author-slide` in
363
+ // theme.base.css). Where each fade lands depends on how many photos there are
364
+ // and on the two timing tokens a project can retune — so the keyframes each
365
+ // strip needs are written here, instead of shipping a set for every count the
366
+ // theme might ever be handed.
367
+ // ---------------------------------------------------------------------------
368
+
369
+ const seconds = (value) => {
370
+ const time = Number.parseFloat(value);
371
+ if (!Number.isFinite(time)) return 0;
372
+ return value.trim().endsWith('ms') ? time / 1000 : time;
373
+ };
374
+
375
+ const strips = document.querySelectorAll('.slides .author-photo');
376
+ if (strips.length) {
377
+ const sheet = document.createElement('style');
378
+ document.head.append(sheet);
379
+ const written = new Set();
380
+
381
+ for (const strip of strips) {
382
+ const photos = [...strip.querySelectorAll('img')];
383
+ photos.forEach((photo, i) => photo.style.setProperty('--i', i));
384
+ // One photo has nothing to fade to, and the CSS already leaves it showing.
385
+ if (photos.length < 2) continue;
386
+
387
+ const css = getComputedStyle(strip);
388
+ const hold = seconds(css.getPropertyValue('--author-hold'));
389
+ const fade = seconds(css.getPropertyValue('--author-fade'));
390
+ const slot = hold + fade;
391
+ if (!slot) continue;
392
+
393
+ const total = photos.length * slot;
394
+ const at = (time) => `${((time / total) * 100).toFixed(3)}%`;
395
+ // Named for what it draws: two strips of the same length and timing — the
396
+ // ordinary case, since both come from the same tokens — share one set.
397
+ const name = `author-fade-${photos.length}-${Math.round(hold * 1000)}-${Math.round(fade * 1000)}`;
398
+ if (!written.has(name)) {
399
+ written.add(name);
400
+ sheet.sheet.insertRule(
401
+ `@keyframes ${name} {` +
402
+ `0% { opacity: 0 }` +
403
+ `${at(fade)}, ${at(slot)} { opacity: 1 }` +
404
+ `${at(slot + fade)}, 100% { opacity: 0 }` +
405
+ `}`,
406
+ );
407
+ }
408
+ strip.style.setProperty('--author-n', photos.length);
409
+ strip.style.setProperty('--author-anim', name);
410
+ }
411
+ }
412
+
297
413
  Reveal.sync();
298
414
 
299
415
  // Which deck are we in? Shown in the corner, hidden on dividers.
package/runtime/edit.js CHANGED
@@ -13,8 +13,13 @@
13
13
  // slide's own range of the .md (the server owns that arithmetic) and reloads:
14
14
  // the URL hash puts the deck back on this slide, and the full pipeline —
15
15
  // mermaid, fragments, highlight steps — runs over the saved source.
16
+ //
17
+ // The `+` over the slide box — or `/` on an empty line — opens the block
18
+ // picker, which writes the markup nobody should have to remember (snippets.js).
16
19
  // ---------------------------------------------------------------------------
17
20
 
21
+ import { createSnippets, PLUS_ICON } from './snippets.js';
22
+
18
23
  // Where the slide came from, stamped on every section by deck.js: the .md file
19
24
  // and the slide's index within it, counted the way the server counts.
20
25
  const SRC = (slide) => {
@@ -116,19 +121,22 @@ const build = (deck) => {
116
121
  panel.id = 'deck-edit';
117
122
  panel.innerHTML = `
118
123
  <header>
119
- <span>edit</span>
124
+ <button type="button" class="deck-edit-wide" aria-pressed="false"></button>
120
125
  <code class="deck-edit-file"></code>
121
126
  <button type="button" class="deck-edit-nav" data-go="-1" title="Previous slide" aria-label="Previous slide">${PREV_ICON}</button>
122
127
  <button type="button" class="deck-edit-nav" data-go="1" title="Next slide" aria-label="Next slide">${NEXT_ICON}</button>
123
128
  <button type="button" class="deck-edit-close" aria-label="Close">${CLOSE_ICON}</button>
124
129
  </header>
125
- <label class="deck-edit-label" for="deck-edit-source">slide</label>
130
+ <div class="deck-edit-row">
131
+ <label class="deck-edit-label" for="deck-edit-source">slide</label>
132
+ <button type="button" class="deck-edit-snip" title="Insert a block (/)" aria-label="Insert a block">${PLUS_ICON}</button>
133
+ </div>
126
134
  <textarea id="deck-edit-source" spellcheck="false"></textarea>
127
135
  <label class="deck-edit-label" for="deck-edit-notes">speaker notes</label>
128
136
  <textarea id="deck-edit-notes" spellcheck="false"></textarea>
129
137
  <footer>
130
- <button type="button" class="deck-edit-add" title="Add a slide after this one">Add</button>
131
- <button type="button" class="deck-edit-delete" title="Delete this slide">Delete</button>
138
+ <button type="button" class="deck-edit-add" title="Add a slide after this one">Add page</button>
139
+ <button type="button" class="deck-edit-delete" title="Delete this slide">Delete page</button>
132
140
  <span class="deck-edit-status" role="status"></span>
133
141
  <button type="button" class="deck-edit-save">Save</button>
134
142
  </footer>
@@ -144,6 +152,7 @@ const build = (deck) => {
144
152
  panel.inert = true;
145
153
 
146
154
  const fileLabel = panel.querySelector('.deck-edit-file');
155
+ const wideButton = panel.querySelector('.deck-edit-wide');
147
156
  const sourceArea = panel.querySelector('#deck-edit-source');
148
157
  const notesArea = panel.querySelector('#deck-edit-notes');
149
158
  const status = panel.querySelector('.deck-edit-status');
@@ -190,6 +199,13 @@ const build = (deck) => {
190
199
  frame ||= requestAnimationFrame(paint);
191
200
  };
192
201
 
202
+ // ---- the block picker ---------------------------------------------------
203
+ // Over the drawer, like the delete question: the deck keeps the stage, and
204
+ // what the picker writes goes through the same preview typing does.
205
+
206
+ const snippets = createSnippets({ area: sourceArea, paint: schedulePaint });
207
+ panel.append(snippets.element);
208
+
193
209
  // ---- open and close ---------------------------------------------------
194
210
  // The deck is narrowed by CSS while the drawer is out (see theme.base.css);
195
211
  // reveal only rescales when told to, so layout() is driven for as long as
@@ -204,6 +220,29 @@ const build = (deck) => {
204
220
  requestAnimationFrame(step);
205
221
  };
206
222
 
223
+ // Half the page for a slide that is mostly markup, the narrow drawer for one
224
+ // that is mostly words. The width is a token, so saying it on the body is
225
+ // enough — the deck, the badge, the signature and the toolbar all step aside
226
+ // through the same `calc()` they already use. It survives the reload a save
227
+ // ends in, which is the only way it could be worth setting at all.
228
+ const WIDE_KEY = 'fb-slides.edit-wide';
229
+
230
+ const setWide = (wide) => {
231
+ document.body.classList.toggle('edit-wide', wide);
232
+ wideButton.setAttribute('aria-pressed', String(wide));
233
+ wideButton.innerHTML = wide ? NEXT_ICON : PREV_ICON;
234
+ const label = wide ? 'Narrow the drawer' : 'Widen the drawer to half the page';
235
+ wideButton.title = label;
236
+ wideButton.setAttribute('aria-label', label);
237
+ try {
238
+ if (wide) sessionStorage.setItem(WIDE_KEY, '1');
239
+ else sessionStorage.removeItem(WIDE_KEY);
240
+ } catch {
241
+ /* walled-off storage only costs the width after a reload */
242
+ }
243
+ reflow();
244
+ };
245
+
207
246
  const setOpen = (open) => {
208
247
  document.body.classList.toggle('edit-open', open);
209
248
  button.setAttribute('aria-pressed', String(open));
@@ -216,6 +255,7 @@ const build = (deck) => {
216
255
  const discard = () => {
217
256
  cancelAnimationFrame(frame);
218
257
  frame = 0;
258
+ snippets.close({ restore: false });
219
259
  confirmBox.hidden = true;
220
260
  editing.slide.innerHTML = editing.html;
221
261
  // The original fragments are back; reveal counts them again.
@@ -230,7 +270,10 @@ const build = (deck) => {
230
270
  deck.layout();
231
271
  };
232
272
 
233
- const open = async () => {
273
+ // `focus` names the box that gets the room and the cursor — navigation and
274
+ // save-reloads pass the one the drawer already had, so following the deck
275
+ // never folds the notes away under a writing hand.
276
+ const open = async (focus = 'source') => {
234
277
  const slide = deck.getCurrentSlide();
235
278
  const src = SRC(slide);
236
279
  if (!src) return;
@@ -266,15 +309,16 @@ const build = (deck) => {
266
309
  fileLabel.textContent = src.file;
267
310
  sourceArea.value = content;
268
311
  notesArea.value = notes;
312
+ snippets.reset();
269
313
  status.textContent = '';
270
314
  saveButton.disabled = false;
271
- panel.dataset.focus = 'source';
315
+ panel.dataset.focus = focus;
272
316
 
273
317
  // One pointer, one tool: the pen, spotlight and pointer stand down — and
274
318
  // stand this drawer down in turn when one of them arms (see below).
275
319
  document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: 'edit' }));
276
320
  setOpen(true);
277
- sourceArea.focus();
321
+ (focus === 'notes' ? notesArea : sourceArea).focus();
278
322
  };
279
323
 
280
324
  const toggle = () => (editing ? close() : open());
@@ -287,9 +331,11 @@ const build = (deck) => {
287
331
 
288
332
  const REOPEN_KEY = 'fb-slides.edit-reopen';
289
333
 
334
+ // The flag's value is the box that was open, so the reload hands the room
335
+ // (and the cursor) back to it.
290
336
  const reloadEditing = () => {
291
337
  try {
292
- sessionStorage.setItem(REOPEN_KEY, '1');
338
+ sessionStorage.setItem(REOPEN_KEY, panel.dataset.focus === 'notes' ? 'notes' : 'source');
293
339
  } catch {
294
340
  /* walled-off storage only costs the reopen */
295
341
  }
@@ -348,6 +394,10 @@ const build = (deck) => {
348
394
 
349
395
  button.addEventListener('click', toggle);
350
396
  panel.querySelector('.deck-edit-close').addEventListener('click', close);
397
+ wideButton.addEventListener('click', () =>
398
+ setWide(!document.body.classList.contains('edit-wide')),
399
+ );
400
+ panel.querySelector('.deck-edit-snip').addEventListener('click', () => snippets.open());
351
401
  saveButton.addEventListener('click', save);
352
402
  sourceArea.addEventListener('input', schedulePaint);
353
403
 
@@ -400,8 +450,10 @@ const build = (deck) => {
400
450
  'keydown',
401
451
  (event) => {
402
452
  if (event.key !== 'Escape' || !editing) return;
403
- // One layer at a time: a pending delete question goes first.
404
- if (confirmBox.hidden) close();
453
+ // One layer at a time, innermost first: the picker, then a pending
454
+ // delete question, then the drawer itself.
455
+ if (snippets.isOpen()) snippets.close();
456
+ else if (confirmBox.hidden) close();
405
457
  else confirmBox.hidden = true;
406
458
  event.stopPropagation();
407
459
  event.preventDefault();
@@ -416,13 +468,22 @@ const build = (deck) => {
416
468
  if (event.detail !== 'edit') close();
417
469
  });
418
470
 
471
+ let wideAtStart = false;
472
+ try {
473
+ wideAtStart = Boolean(sessionStorage.getItem(WIDE_KEY));
474
+ } catch {
475
+ /* walled-off storage: the drawer opens at its usual width */
476
+ }
477
+ setWide(wideAtStart);
478
+
419
479
  // A write reloaded the page with the drawer out: pick the editing back up
420
480
  // on whatever slide the hash landed on.
421
481
  try {
422
- if (sessionStorage.getItem(REOPEN_KEY)) {
482
+ const reopen = sessionStorage.getItem(REOPEN_KEY);
483
+ if (reopen) {
423
484
  sessionStorage.removeItem(REOPEN_KEY);
424
- if (deck.isReady?.()) open();
425
- else deck.on('ready', open);
485
+ if (deck.isReady?.()) open(reopen);
486
+ else deck.on('ready', () => open(reopen));
426
487
  }
427
488
  } catch {
428
489
  /* walled-off storage: the drawer just stays closed */
@@ -433,8 +494,9 @@ const build = (deck) => {
433
494
  // overview is a different surface altogether, so there it does close.
434
495
  deck.on('slidechanged', async () => {
435
496
  if (!editing) return;
497
+ const focus = panel.dataset.focus;
436
498
  discard();
437
- await open();
499
+ await open(focus);
438
500
  // The new slide would not load — a drawer left standing would lie about
439
501
  // what it edits.
440
502
  if (!editing) setOpen(false);