fb-slides 0.5.0 → 0.6.2

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 CHANGED
@@ -127,6 +127,63 @@ 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
+ ### Blocks
131
+
132
+ Six classes cover the layouts a talk keeps needing, so a slide asks for one by name
133
+ instead of carrying a paragraph of inline styles:
134
+
135
+ | | |
136
+ | --- | --- |
137
+ | `.cols` / `.col` | two columns — with the `min-width: 0` a code block falls over without |
138
+ | `.frame` | an embed that shares the slide instead of taking it whole |
139
+ | `.caption` | a quiet line under a figure |
140
+ | `.box` | a bordered block: the point the slide comes back to |
141
+ | `.author-slide` | the speaker page — photos down the left edge, the bio on the right |
142
+ | `<img>`, `<video>` | centred, unframed, and never wider than the slide |
143
+
144
+ ````markdown
145
+ <div class="cols">
146
+ <div class="col">
147
+
148
+ **In the widget**
149
+
150
+ ```js
151
+ app.openLink({ url: 'https://example.com/' });
152
+ ```
153
+
154
+ </div>
155
+ <div class="col">
156
+
157
+ The blank lines matter: Markdown inside a block-level tag is only parsed
158
+ when there is one on each side of it.
159
+
160
+ </div>
161
+ </div>
162
+ ````
163
+
164
+ Each of them fixes its numbers in CSS variables — `--cols-gap`, `--frame-w`,
165
+ `--frame-h`, `--author-photo`, `--author-hold` — so a project retunes one from its own
166
+ `theme.css` without restating the rule.
167
+
168
+ The speaker page is markup you write and the theme lays out. Give `.author-photo` as
169
+ many `<img>` as you like: they cross-fade in the order they are written, on a loop that
170
+ restarts every time the slide comes up.
171
+
172
+ ```markdown
173
+ <!-- .slide: class="author-slide" -->
174
+
175
+ <div class="author-photo">
176
+ <img src="assets/author/on-stage.jpg" alt="" />
177
+ <img src="assets/author/portrait.jpg" alt="" />
178
+ </div>
179
+
180
+ <div class="author-bio">
181
+ <h1>Your name</h1>
182
+ <ul><li>What you do</li></ul>
183
+ <p class="author-meta">The stack · you · work · with</p>
184
+ </div>
185
+ ```
186
+
130
187
  ### Editing in the browser
131
188
 
132
189
  On the dev server, `T` (or the last button on the toolbar) opens the slide you
@@ -140,6 +197,22 @@ right after the current one and opens it; **Delete** removes the slide from its
140
197
  file, after asking. Clicking into the notes box trades the room with the slide
141
198
  box, so both are comfortable to write in.
142
199
 
200
+ The `+` over the slide box — or `/` typed on an empty line — opens the block
201
+ picker: the demo marker, a framed embed, a video with the attributes that make
202
+ it behave, two columns, a fenced block, the speaker page. The block lands where
203
+ the cursor was, with its first placeholder selected so it is ready to type over;
204
+ text already selected takes that placeholder's place, so picking **Fragment**
205
+ wraps the paragraph that was highlighted. A block that only works at the top of
206
+ a slide, like the demo marker, goes there whatever the cursor was doing.
207
+
208
+ `snippets:` in `slides.config.js` adds a project's own blocks to the list:
209
+
210
+ ```js
211
+ snippets: [
212
+ { label: 'Pricing table', hint: 'the three tiers', body: '<div class="tiers">${…}</div>' },
213
+ ],
214
+ ```
215
+
143
216
  The drawer exists only under `fb-slides dev`: a built deck is static files and
144
217
  never shows the button.
145
218
 
@@ -194,6 +267,7 @@ export default {
194
267
  outDir: 'dist',
195
268
  exclude: ['dist'], // extra path segments the build skips
196
269
  fragmentLists: true, // bullet lists reveal one item at a time
270
+ snippets: [], // your own blocks in the edit drawer's picker
197
271
  reveal: { transition: 'fade' },// passed to Reveal.initialize()
198
272
  mermaid: {}, // passed to mermaid.initialize()
199
273
  };
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.0",
3
+ "version": "0.6.2",
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": [
@@ -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
@@ -294,6 +294,58 @@ for (const pre of document.querySelectorAll('.slides pre')) {
294
294
  if (lines > 14) pre.classList.add('pre--dense');
295
295
  }
296
296
 
297
+ // ---------------------------------------------------------------------------
298
+ // The speaker page's photos cross-fade in DOM order (`.author-slide` in
299
+ // theme.base.css). Where each fade lands depends on how many photos there are
300
+ // and on the two timing tokens a project can retune — so the keyframes each
301
+ // strip needs are written here, instead of shipping a set for every count the
302
+ // theme might ever be handed.
303
+ // ---------------------------------------------------------------------------
304
+
305
+ const seconds = (value) => {
306
+ const time = Number.parseFloat(value);
307
+ if (!Number.isFinite(time)) return 0;
308
+ return value.trim().endsWith('ms') ? time / 1000 : time;
309
+ };
310
+
311
+ const strips = document.querySelectorAll('.slides .author-photo');
312
+ if (strips.length) {
313
+ const sheet = document.createElement('style');
314
+ document.head.append(sheet);
315
+ const written = new Set();
316
+
317
+ for (const strip of strips) {
318
+ const photos = [...strip.querySelectorAll('img')];
319
+ photos.forEach((photo, i) => photo.style.setProperty('--i', i));
320
+ // One photo has nothing to fade to, and the CSS already leaves it showing.
321
+ if (photos.length < 2) continue;
322
+
323
+ const css = getComputedStyle(strip);
324
+ const hold = seconds(css.getPropertyValue('--author-hold'));
325
+ const fade = seconds(css.getPropertyValue('--author-fade'));
326
+ const slot = hold + fade;
327
+ if (!slot) continue;
328
+
329
+ const total = photos.length * slot;
330
+ const at = (time) => `${((time / total) * 100).toFixed(3)}%`;
331
+ // Named for what it draws: two strips of the same length and timing — the
332
+ // ordinary case, since both come from the same tokens — share one set.
333
+ const name = `author-fade-${photos.length}-${Math.round(hold * 1000)}-${Math.round(fade * 1000)}`;
334
+ if (!written.has(name)) {
335
+ written.add(name);
336
+ sheet.sheet.insertRule(
337
+ `@keyframes ${name} {` +
338
+ `0% { opacity: 0 }` +
339
+ `${at(fade)}, ${at(slot)} { opacity: 1 }` +
340
+ `${at(slot + fade)}, 100% { opacity: 0 }` +
341
+ `}`,
342
+ );
343
+ }
344
+ strip.style.setProperty('--author-n', photos.length);
345
+ strip.style.setProperty('--author-anim', name);
346
+ }
347
+ }
348
+
297
349
  Reveal.sync();
298
350
 
299
351
  // 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);
@@ -198,7 +198,15 @@ const RevealPointer = () => ({
198
198
  const setPointer = (on) => {
199
199
  armed = on;
200
200
  button.setAttribute('aria-pressed', String(on));
201
- toolbar.classList.toggle('is-armed', on);
201
+ // Recomputed from every module's button, not toggled with this one's
202
+ // boolean: the arming handshake runs the loser's stand-down after the
203
+ // winner set the class, and the last writer would close the bar with a
204
+ // tool still armed. The edit chip's pressed state is the drawer, so it
205
+ // does not count.
206
+ toolbar.classList.toggle(
207
+ 'is-armed',
208
+ !!toolbar.querySelector('.deck-tool[aria-pressed="true"]:not(#tool-edit)'),
209
+ );
202
210
  picker.hidden = !on;
203
211
  applyCursor();
204
212
  if (on) document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: 'pointer' }));