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.
@@ -85,8 +85,17 @@ const rowsFor = (deck) => {
85
85
  const rows = [];
86
86
  let section = null;
87
87
  let dividerSeen = false;
88
+ let number = 0;
88
89
 
89
- for (const [index, slide] of deck.getSlides().entries()) {
90
+ // Not `deck.getSlides()`: reveal leaves the disabled slides out of that list,
91
+ // and a slide that is out of the talk is exactly what someone opens the
92
+ // navigator to reach. They are listed, marked, and numbered by what they are
93
+ // — nothing, since the deck is counted as if they were not there.
94
+ const slides = [...document.querySelectorAll('.reveal .slides section')].filter(
95
+ (slide) => !slide.classList.contains('stack'),
96
+ );
97
+
98
+ for (const slide of slides) {
90
99
  const stack = slide.parentElement.matches('section') ? slide.parentElement : null;
91
100
  const owner = stack ?? slide;
92
101
  const label = owner.dataset.deck ?? '';
@@ -105,8 +114,10 @@ const rowsFor = (deck) => {
105
114
  const vertical = Boolean(stack) && slide !== stack.firstElementChild;
106
115
  const depth = vertical ? base + 1 : base;
107
116
 
108
- slide.dataset.outlineNumber = index + 1;
109
- rows.push({ kind: 'slide', slide, depth, number: index + 1, divider, vertical });
117
+ const off = slide.classList.contains('slide-off');
118
+ if (!off) number += 1;
119
+ slide.dataset.outlineNumber = off ? '' : number;
120
+ rows.push({ kind: 'slide', slide, depth, number: off ? null : number, divider, vertical, off });
110
121
  }
111
122
  return rows;
112
123
  };
@@ -241,13 +252,19 @@ const RevealOutline = () => ({
241
252
  item.dataset.depth = Math.min(row.depth, 2);
242
253
  if (row.divider) item.classList.add('is-divider');
243
254
  if (row.vertical) item.classList.add('is-vertical');
255
+ if (row.off) item.classList.add('is-off');
256
+ // A disabled slide has no number to show, so the corner says what it
257
+ // has instead of one.
258
+ const number = row.off
259
+ ? '<span class="deck-outline-n is-off">off</span>'
260
+ : `<span class="deck-outline-n">${row.number}</span>`;
244
261
  if (view === 'preview') {
245
- item.innerHTML = `<span class="deck-outline-n">${row.number}</span>`;
262
+ item.innerHTML = number;
246
263
  item.prepend(thumbFor(row.slide));
247
264
  // No room for the title in the row; it moves to the tooltip.
248
265
  item.title = titleOf(row.slide);
249
266
  } else {
250
- item.innerHTML = `<span class="deck-outline-title"></span><span class="deck-outline-n">${row.number}</span>`;
267
+ item.innerHTML = `<span class="deck-outline-title"></span>${number}`;
251
268
  item.querySelector('.deck-outline-title').textContent = titleOf(row.slide);
252
269
  }
253
270
  return item;
@@ -303,6 +320,8 @@ const RevealOutline = () => ({
303
320
  const item = event.target.closest('.deck-outline-item');
304
321
  if (!item) return;
305
322
  const { h, v } = deck.getIndices(rows[item.dataset.row].slide);
323
+ // Picked by hand: a disabled slide is shown rather than walked past.
324
+ document.dispatchEvent(new CustomEvent('deck:jump'));
306
325
  deck.slide(h, v);
307
326
  // Clicked with the mouse, the button would keep the focus and swallow the
308
327
  // next Space as a re-click. Activated from the keyboard (`detail === 0`)
@@ -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' }));
@@ -0,0 +1,319 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The block picker — the `+` in the edit drawer, and `/` on an empty line.
3
+ //
4
+ // A snippet is the markup you would otherwise have to remember: the attributes
5
+ // a <video> needs to behave, the `min-width: 0` two columns fall over without,
6
+ // the comment that turns a slide into a live demo. Nothing here renders — the
7
+ // classes it reaches for are the slide vocabulary in theme.base.css.
8
+ //
9
+ // `${…}` in a body marks something to type over: the braces are dropped on the
10
+ // way in and the first one is left selected, so a snippet lands ready to fill.
11
+ // Text selected in the textarea takes the place of that first one — select a
12
+ // paragraph, pick Fragment, and the paragraph is what gets wrapped.
13
+ //
14
+ // `snippets:` in slides.config.js adds a project's own blocks to the list.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const CFG = window.__FB_SLIDES__ ?? {};
18
+
19
+ // `top: true` — the block only works as the first thing in a slide, so that is
20
+ // where it goes, wherever the cursor happens to be.
21
+ const BUILT_IN = [
22
+ {
23
+ label: 'Demo, full slide',
24
+ hint: 'a folder in demo/, running on the slide',
25
+ top: true,
26
+ body: '<!-- demo: ${counter} -->',
27
+ },
28
+ {
29
+ label: 'External page, full slide',
30
+ hint: 'any URL, taking the whole slide',
31
+ top: true,
32
+ body: '<!-- demo: ${https://example.com/} -->',
33
+ },
34
+ {
35
+ label: 'Framed embed',
36
+ hint: 'an iframe that shares the slide with the text',
37
+ body: '<div class="frame">\n <iframe src="${https://example.com/}"></iframe>\n</div>',
38
+ },
39
+ {
40
+ label: 'Video',
41
+ hint: 'a clip from assets/, with the attributes that make it behave',
42
+ body:
43
+ '<video src="assets/${clip.mp4}" controls muted playsinline preload="metadata"\n' +
44
+ ' style="width: 78%; aspect-ratio: 16 / 9;"></video>',
45
+ },
46
+ {
47
+ label: 'Image',
48
+ hint: 'centred, no frame around it',
49
+ body: '![](assets/${shot.png})',
50
+ },
51
+ {
52
+ label: 'Two columns',
53
+ hint: 'code on one side, words on the other',
54
+ body:
55
+ '<div class="cols">\n<div class="col">\n\n${left}\n\n</div>\n' +
56
+ '<div class="col">\n\n${right}\n\n</div>\n</div>',
57
+ },
58
+ {
59
+ label: 'Code',
60
+ hint: 'a fenced block, highlighted',
61
+ body: '```${ts}\n${code}\n```',
62
+ },
63
+ {
64
+ label: 'Code, stepped',
65
+ hint: 'the block reveals itself a range of lines at a time',
66
+ body: '```${ts} [1-3|5-7|9]\n${code}\n```',
67
+ },
68
+ {
69
+ label: 'Diagram',
70
+ hint: 'a mermaid graph, drawn at load',
71
+ body: '```mermaid\nflowchart LR\n A[${Client}] --> B[Server]\n```',
72
+ },
73
+ {
74
+ label: 'Fragment',
75
+ hint: 'appears on the next press, on its own',
76
+ body: '<p class="fragment">${text}</p>',
77
+ },
78
+ {
79
+ label: 'Callout',
80
+ hint: 'a bordered block — the point the slide comes back to',
81
+ body: '<div class="box">\n\n${text}\n\n</div>',
82
+ },
83
+ {
84
+ label: 'Caption',
85
+ hint: 'a quiet line under a figure',
86
+ body: '<p class="caption">${text}</p>',
87
+ },
88
+ {
89
+ label: 'Speaker page',
90
+ hint: 'photos down the left edge, who you are on the right',
91
+ top: true,
92
+ body:
93
+ '<!-- .slide: class="author-slide" -->\n\n' +
94
+ '<div class="author-photo">\n' +
95
+ ' <img src="assets/author/${portrait.jpg}" alt="" />\n' +
96
+ ' <img src="assets/author/on-stage.jpg" alt="" />\n' +
97
+ '</div>\n\n' +
98
+ '<div class="author-bio">\n' +
99
+ ' <h1>Your name</h1>\n' +
100
+ ' <ul>\n' +
101
+ ' <li>What you do</li>\n' +
102
+ ' <li>What you are known for</li>\n' +
103
+ ' </ul>\n' +
104
+ ' <p class="author-meta">The stack · you · work · with</p>\n' +
105
+ ' <p class="author-meta">❤️ Life outside work — <strong>yoursite.dev</strong></p>\n' +
106
+ '</div>',
107
+ },
108
+ {
109
+ label: 'Disabled',
110
+ hint: 'keep the slide, leave it out of the talk',
111
+ top: true,
112
+ body: '<!-- disabled -->',
113
+ },
114
+ {
115
+ label: 'Slide class',
116
+ hint: 'hand this slide a class of your own',
117
+ top: true,
118
+ body: '<!-- .slide: class="${name}" -->',
119
+ },
120
+ ];
121
+
122
+ // The project's own blocks land after the built-in ones. A snippet without a
123
+ // body would be an entry that inserts nothing, so it never reaches the list.
124
+ const catalog = () => [
125
+ ...BUILT_IN,
126
+ ...(Array.isArray(CFG.snippets) ? CFG.snippets : []).filter(
127
+ (snippet) => snippet && typeof snippet.body === 'string',
128
+ ),
129
+ ];
130
+
131
+ const PLACEHOLDER = /\$\{([^}]*)\}/g;
132
+
133
+ // Unwrap every `${…}` to the word inside it, and hand back where the first one
134
+ // ended up — that is the range the cursor lands on. `taken` is the text the
135
+ // editor had selected, which stands in for that first placeholder.
136
+ const fill = (body, taken) => {
137
+ let text = '';
138
+ let range = null;
139
+ let read = 0;
140
+ for (const match of body.matchAll(PLACEHOLDER)) {
141
+ const word = range === null && taken ? taken : match[1];
142
+ text += body.slice(read, match.index) + word;
143
+ range ??= [text.length - word.length, text.length];
144
+ read = match.index + match[0].length;
145
+ }
146
+ return { text: text + body.slice(read), range };
147
+ };
148
+
149
+ // A block wants a blank line around it, and exactly one: pasting into the
150
+ // middle of a paragraph should not glue the markup onto a sentence, and
151
+ // pasting into a gap should not open a second one.
152
+ const gap = (text, atEnd) => {
153
+ if (!text) return '';
154
+ const touching = atEnd ? /\n\n$/.test(text) : /^\n\n/.test(text);
155
+ if (touching) return '';
156
+ return (atEnd ? /\n$/.test(text) : /^\n/.test(text)) ? '\n' : '\n\n';
157
+ };
158
+
159
+ export const PLUS_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
160
+ stroke-linecap="round" aria-hidden="true">
161
+ <path d="M12 6v12" /><path d="M6 12h12" />
162
+ </svg>`;
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // The picker itself. `area` is the slide's textarea; `paint` re-renders the
166
+ // slide the way typing into it does.
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export const createSnippets = ({ area, paint }) => {
170
+ const element = document.createElement('div');
171
+ element.className = 'deck-snip';
172
+ element.hidden = true;
173
+ element.innerHTML = `
174
+ <input class="deck-snip-filter" type="text" spellcheck="false" autocomplete="off"
175
+ placeholder="Insert a block…" aria-label="Find a block" />
176
+ <ul class="deck-snip-list" role="listbox"></ul>
177
+ `;
178
+
179
+ const filter = element.querySelector('.deck-snip-filter');
180
+ const list = element.querySelector('.deck-snip-list');
181
+
182
+ const blocks = catalog();
183
+ list.innerHTML = blocks
184
+ .map(
185
+ (snippet, index) =>
186
+ `<li role="option" data-index="${index}"><span>${snippet.label}</span>` +
187
+ `<small>${snippet.hint ?? ''}</small></li>`,
188
+ )
189
+ .join('');
190
+ const items = [...list.children];
191
+
192
+ // Where the block goes, decided when the picker opens: the cursor at that
193
+ // moment, or the `/` that summoned it. `take` says whether what that range
194
+ // covers is the author's own text, to be threaded through the snippet.
195
+ let target = null;
196
+
197
+ // The cursor as it was when the textarea last had it. A textarea that has
198
+ // never been clicked reports 0, which would drop every block at the top of
199
+ // the slide instead of at the end of what is written.
200
+ let caret = [0, 0];
201
+ const remember = () => {
202
+ caret = [area.selectionStart, area.selectionEnd];
203
+ };
204
+ for (const event of ['keyup', 'click', 'select', 'blur']) area.addEventListener(event, remember);
205
+
206
+ // A new slide in the drawer: the old cursor belonged to the old text.
207
+ const reset = () => {
208
+ caret = [area.value.length, area.value.length];
209
+ };
210
+
211
+ const current = () => list.querySelector('.is-current');
212
+
213
+ const highlight = (item) => {
214
+ current()?.classList.remove('is-current');
215
+ item?.classList.add('is-current');
216
+ item?.scrollIntoView({ block: 'nearest' });
217
+ };
218
+
219
+ const apply = () => {
220
+ const shown = items.filter((item) => !item.hidden);
221
+ if (!shown.length) return;
222
+ highlight(shown.includes(current()) ? current() : shown[0]);
223
+ };
224
+
225
+ const search = () => {
226
+ const query = filter.value.trim().toLowerCase();
227
+ for (const item of items) {
228
+ const snippet = blocks[Number(item.dataset.index)];
229
+ item.hidden = Boolean(query) && !`${snippet.label} ${snippet.hint ?? ''}`.toLowerCase().includes(query);
230
+ }
231
+ apply();
232
+ };
233
+
234
+ const close = ({ restore = true } = {}) => {
235
+ if (element.hidden) return;
236
+ element.hidden = true;
237
+ target = null;
238
+ if (!restore) return;
239
+ area.focus();
240
+ area.setSelectionRange(caret[0], caret[1]);
241
+ };
242
+
243
+ const open = (where) => {
244
+ target = where ?? {
245
+ // Live if the cursor is still in the box, remembered if it moved on.
246
+ ...(document.activeElement === area
247
+ ? { from: area.selectionStart, to: area.selectionEnd }
248
+ : { from: caret[0], to: caret[1] }),
249
+ take: true,
250
+ };
251
+ filter.value = '';
252
+ search();
253
+ element.hidden = false;
254
+ filter.focus();
255
+ };
256
+
257
+ const insert = (snippet) => {
258
+ const { from, to, take } = target ?? { from: area.value.length, to: area.value.length };
259
+ // A block that only works at the top of a slide goes there whatever the
260
+ // cursor was doing, and never swallows a selection on the way.
261
+ const at = snippet.top ? { from: 0, to: 0, take: false } : { from, to, take };
262
+
263
+ const before = area.value.slice(0, at.from);
264
+ const after = area.value.slice(at.to);
265
+ const { text, range } = fill(snippet.body, at.take ? area.value.slice(at.from, at.to) : '');
266
+
267
+ const lead = gap(before, true);
268
+ const trail = gap(after, false);
269
+ area.value = before + lead + text + trail + after;
270
+
271
+ const start = before.length + lead.length;
272
+ close({ restore: false });
273
+ area.focus();
274
+ if (range) area.setSelectionRange(start + range[0], start + range[1]);
275
+ else area.setSelectionRange(start + text.length, start + text.length);
276
+ remember();
277
+ paint();
278
+ };
279
+
280
+ // ---- wiring -------------------------------------------------------------
281
+
282
+ filter.addEventListener('input', search);
283
+
284
+ filter.addEventListener('keydown', (event) => {
285
+ const shown = items.filter((item) => !item.hidden);
286
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
287
+ event.preventDefault();
288
+ const step = event.key === 'ArrowDown' ? 1 : -1;
289
+ const index = shown.indexOf(current());
290
+ highlight(shown[(index + step + shown.length) % shown.length]);
291
+ } else if (event.key === 'Enter') {
292
+ event.preventDefault();
293
+ const item = current();
294
+ if (item) insert(blocks[Number(item.dataset.index)]);
295
+ }
296
+ });
297
+
298
+ list.addEventListener('click', (event) => {
299
+ const item = event.target.closest('li');
300
+ if (item) insert(blocks[Number(item.dataset.index)]);
301
+ });
302
+ list.addEventListener('mousemove', (event) => {
303
+ const item = event.target.closest('li');
304
+ if (item && item !== current()) highlight(item);
305
+ });
306
+
307
+ // `/` on an empty line opens the same list without leaving the keyboard, and
308
+ // the block takes the place of the slash.
309
+ area.addEventListener('input', (event) => {
310
+ if (event.inputType !== 'insertText' || event.data !== '/') return;
311
+ const at = area.selectionStart - 1;
312
+ if (at > 0 && area.value[at - 1] !== '\n') return;
313
+ open({ from: at, to: at + 1, take: false });
314
+ });
315
+
316
+ return { element, open, close, isOpen: () => !element.hidden, reset };
317
+ };
318
+
319
+ export default createSnippets;
@@ -142,7 +142,15 @@ const RevealSpotlight = () => ({
142
142
  const setSpot = (on) => {
143
143
  armed = on;
144
144
  button.setAttribute('aria-pressed', String(on));
145
- toolbar.classList.toggle('is-armed', on);
145
+ // Recomputed from every module's button, not toggled with this one's
146
+ // boolean: the arming handshake runs the loser's stand-down after the
147
+ // winner set the class, and the last writer would close the bar with a
148
+ // tool still armed. The edit chip's pressed state is the drawer, so it
149
+ // does not count.
150
+ toolbar.classList.toggle(
151
+ 'is-armed',
152
+ !!toolbar.querySelector('.deck-tool[aria-pressed="true"]:not(#tool-edit)'),
153
+ );
146
154
  // Arming must start from a whole veil: a hole still mid-fade from last
147
155
  // time would leak the pointer through (see clear above).
148
156
  if (on) veil.style.clipPath = '';