fb-slides 0.1.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/bin/fb-slides.mjs +111 -0
  4. package/lib/build.mjs +98 -0
  5. package/lib/config.mjs +90 -0
  6. package/lib/create.mjs +59 -0
  7. package/lib/decks.mjs +13 -0
  8. package/lib/dev.mjs +94 -0
  9. package/lib/render.mjs +52 -0
  10. package/lib/server.mjs +168 -0
  11. package/lib/vendor.mjs +32 -0
  12. package/package.json +51 -0
  13. package/runtime/annotate.js +458 -0
  14. package/runtime/deck.js +299 -0
  15. package/runtime/index.html +37 -0
  16. package/runtime/outline.js +354 -0
  17. package/runtime/shortcuts.js +53 -0
  18. package/runtime/spotlight.js +293 -0
  19. package/runtime/theme.base.css +879 -0
  20. package/templates/starter/README.md +27 -0
  21. package/templates/starter/_gitignore +4 -0
  22. package/templates/starter/_package.json +14 -0
  23. package/templates/starter/assets/.gitkeep +0 -0
  24. package/templates/starter/decks/01-intro.md +44 -0
  25. package/templates/starter/decks/02-demos.md +34 -0
  26. package/templates/starter/demo/angular-hello/README.md +15 -0
  27. package/templates/starter/demo/angular-hello/_package.json +24 -0
  28. package/templates/starter/demo/angular-hello/angular.json +34 -0
  29. package/templates/starter/demo/angular-hello/src/index.html +12 -0
  30. package/templates/starter/demo/angular-hello/src/main.ts +18 -0
  31. package/templates/starter/demo/angular-hello/src/styles.css +22 -0
  32. package/templates/starter/demo/angular-hello/tsconfig.app.json +5 -0
  33. package/templates/starter/demo/angular-hello/tsconfig.json +17 -0
  34. package/templates/starter/demo/counter/index.html +34 -0
  35. package/templates/starter/slides.config.js +34 -0
  36. package/templates/starter/theme.css +14 -0
@@ -0,0 +1,354 @@
1
+ // ---------------------------------------------------------------------------
2
+ // RevealOutline — the deck as a list, down the left edge.
3
+ //
4
+ // Reveal's own overview (`O`) lays the slides out as a grid of thumbnails: good
5
+ // for "where am I", useless for "jump to the bit about X" once a talk is fifty
6
+ // slides long. This is the other view — every slide by its title, in a column
7
+ // you can read and scroll, with the structure of the decks still visible.
8
+ //
9
+ // It pushes the deck aside rather than covering it: the slide stays readable
10
+ // while you look for the next one, which is the whole point on stage.
11
+ //
12
+ // A reveal.js plugin: `init()` gets the deck instance, so this file owns the
13
+ // feature end to end — its chrome, its list and its key binding. The look lives
14
+ // in theme.base.css with the rest of the chrome, so a project can reskin it.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ import { listShortcutAfter } from './shortcuts.js';
18
+
19
+ const MENU_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
20
+ stroke-linecap="round" aria-hidden="true">
21
+ <path d="M4 7h16" /><path d="M4 12h16" /><path d="M4 17h16" />
22
+ </svg>`;
23
+
24
+ const CLOSE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
25
+ stroke-linecap="round" aria-hidden="true">
26
+ <path d="M6 6l12 12" /><path d="M18 6L6 18" />
27
+ </svg>`;
28
+
29
+ // The view toggle wears the mode it would switch to, not the one it is in:
30
+ // reading titles, you see the image; browsing previews, you see the list.
31
+ const IMAGE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
32
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
33
+ <rect x="4" y="5" width="16" height="14" rx="2" />
34
+ <circle cx="9.2" cy="10.2" r="1.5" />
35
+ <path d="M4 16.8l4.6-4.6 3.4 3.4 3.2-3.2 4.8 4.8" />
36
+ </svg>`;
37
+
38
+ const LIST_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
39
+ stroke-linecap="round" aria-hidden="true">
40
+ <path d="M9 6.5h11" /><path d="M9 12h11" /><path d="M9 17.5h11" />
41
+ <path d="M4.5 6.5h.01" /><path d="M4.5 12h.01" /><path d="M4.5 17.5h.01" />
42
+ </svg>`;
43
+
44
+ // Which face of the panel you last used — titles or previews — is a durable
45
+ // preference, not per-deck state, so it survives the tab.
46
+ const VIEW_KEY = 'fb-slides.outline-view';
47
+
48
+ // Long enough to read as a movement, short enough not to be waited on. The
49
+ // panel's slide-in and the deck's reflow share it — see theme.base.css.
50
+ const SLIDE_MS = 260;
51
+
52
+ // The key the panel answers to, how the help overlay spells it, and what it
53
+ // calls it there — one row below reveal's own overview, which is what this is
54
+ // the vertical answer to.
55
+ //
56
+ // `V` is one of reveal's three black-screen keys, and a plugin binding wins over
57
+ // it — which is the point: the navigator is reached for far more often, and `B`
58
+ // and `.` still black the screen. Reveal's help row for pause reads `B , .`
59
+ // and never listed `V`, so nothing there needs correcting.
60
+ const KEY_CODE = 86;
61
+ const HELP_ROW = { key: 'V', action: 'Vertical Slide Overview', after: /overview/i };
62
+
63
+ // What to call a slide in the list. The rendered heading is the title the
64
+ // audience saw; a demo slide has no heading, so its header names the demo.
65
+ const titleOf = (slide) => {
66
+ if (slide.classList.contains('demo-slide')) {
67
+ return slide.querySelector('header code')?.textContent.trim() ?? 'demo';
68
+ }
69
+ const heading = slide.querySelector(':scope > h1, :scope > h2, :scope > h3');
70
+ if (heading) return heading.textContent.trim();
71
+ // No heading at all — a slide that is one image, one quote, one line of code.
72
+ // Its own first words are still a better handle than "slide 14".
73
+ const text = slide.textContent.replace(/\s+/g, ' ').trim();
74
+ return text ? text.slice(0, 70) : `slide ${slide.dataset.outlineNumber ?? ''}`.trim();
75
+ };
76
+
77
+ // The list, in reading order: one row per deck as it starts, one per slide.
78
+ //
79
+ // Depth is the talk's own structure, not a tree someone has to maintain. A
80
+ // slide opening with an `# h1` is a deck divider, so the slides that follow it
81
+ // are what it introduces and sit one step in. A deck that has no divider has
82
+ // nothing to hang off, and stays flush. Reveal's vertical slides — should a
83
+ // deck use them — indent once more under the slide they hang off.
84
+ const rowsFor = (deck) => {
85
+ const rows = [];
86
+ let section = null;
87
+ let dividerSeen = false;
88
+
89
+ for (const [index, slide] of deck.getSlides().entries()) {
90
+ const stack = slide.parentElement.matches('section') ? slide.parentElement : null;
91
+ const owner = stack ?? slide;
92
+ const label = owner.dataset.deck ?? '';
93
+
94
+ if (label !== section) {
95
+ section = label;
96
+ dividerSeen = false;
97
+ if (label) rows.push({ kind: 'section', label });
98
+ }
99
+
100
+ const divider = owner.classList.contains('deck-title');
101
+ if (divider) dividerSeen = true;
102
+ const base = divider || !dividerSeen ? 0 : 1;
103
+ // A vertical slide is not just one deeper — the preview view insets it from
104
+ // both sides, so it reads as stacked under its slide, not as a new section.
105
+ const vertical = Boolean(stack) && slide !== stack.firstElementChild;
106
+ const depth = vertical ? base + 1 : base;
107
+
108
+ slide.dataset.outlineNumber = index + 1;
109
+ rows.push({ kind: 'slide', slide, depth, number: index + 1, divider, vertical });
110
+ }
111
+ return rows;
112
+ };
113
+
114
+ const RevealOutline = () => ({
115
+ id: 'outline',
116
+
117
+ init(deck) {
118
+ const button = document.createElement('button');
119
+ button.id = 'deck-menu';
120
+ button.type = 'button';
121
+ button.title = 'Slides (V)';
122
+ button.setAttribute('aria-label', 'Slides');
123
+ button.innerHTML = MENU_ICON;
124
+
125
+ const panel = document.createElement('aside');
126
+ panel.id = 'deck-outline';
127
+ panel.innerHTML = `
128
+ <header>
129
+ <span>slides</span>
130
+ <button type="button" class="deck-outline-view"></button>
131
+ <button type="button" class="deck-outline-close" aria-label="Close">${CLOSE_ICON}</button>
132
+ </header>
133
+ <nav class="deck-outline-list"></nav>
134
+ `;
135
+
136
+ document.body.append(button, panel);
137
+ const list = panel.querySelector('.deck-outline-list');
138
+ const viewButton = panel.querySelector('.deck-outline-view');
139
+
140
+ // ---------------------------------------------------------------------
141
+ // Open and close. The deck itself is narrowed by CSS, and reveal only
142
+ // rescales a slide when it is told to — so drive `layout()` for as long as
143
+ // the width is moving, or the slide would snap to its new size at the end.
144
+ // ---------------------------------------------------------------------
145
+
146
+ let open = false;
147
+
148
+ const reflow = () => {
149
+ const until = performance.now() + SLIDE_MS;
150
+ const step = () => {
151
+ deck.layout();
152
+ if (performance.now() < until) requestAnimationFrame(step);
153
+ };
154
+ requestAnimationFrame(step);
155
+ };
156
+
157
+ const setOpen = (next) => {
158
+ if (next === open) return;
159
+ open = next;
160
+ document.body.classList.toggle('outline-open', open);
161
+ button.setAttribute('aria-expanded', String(open));
162
+ // Closed, it is still on the page for the transition — but out of reach
163
+ // of the pointer and of Tab.
164
+ panel.inert = !open;
165
+ if (open) scrollToCurrent();
166
+ reflow();
167
+ };
168
+
169
+ // ---------------------------------------------------------------------
170
+ // Two faces of the same list: every slide by its title, or every slide as
171
+ // itself — a live clone on a deck-sized stage, scaled to the row.
172
+ // ---------------------------------------------------------------------
173
+
174
+ let view = 'list';
175
+ try {
176
+ if (localStorage.getItem(VIEW_KEY) === 'preview') view = 'preview';
177
+ } catch {
178
+ /* storage can be walled off (file://, private mode); the default stands */
179
+ }
180
+
181
+ // The stage is deck-sized so the theme lays the clone out exactly as it did
182
+ // the slide, then one transform shrinks it to the row. The scale is the
183
+ // measured row width over the deck width — set on the list, not per thumb,
184
+ // since every row is as wide as the panel.
185
+ const config = deck.getConfig();
186
+ list.style.setProperty('--deck-w', `${config.width}px`);
187
+ list.style.setProperty('--deck-h', `${config.height}px`);
188
+ list.style.setProperty('--deck-ratio', `${config.width} / ${config.height}`);
189
+
190
+ // Per stage, not once on the list: indented rows (a divider's slides, a
191
+ // vertical stack) have narrower thumbs, so each gets its own scale.
192
+ const syncThumbScale = () => {
193
+ for (const thumb of list.querySelectorAll('.deck-outline-thumb')) {
194
+ thumb.style.setProperty('--thumb-scale', thumb.clientWidth / config.width);
195
+ }
196
+ };
197
+
198
+ const thumbFor = (slide) => {
199
+ const clone = slide.cloneNode(true);
200
+ // Undo what reveal did to keep the original off-screen. Inline styles go
201
+ // wholesale: layout inside the stage is the stylesheet's job.
202
+ clone.removeAttribute('style');
203
+ clone.removeAttribute('hidden');
204
+ clone.removeAttribute('aria-hidden');
205
+ clone.classList.remove('past', 'present', 'future', 'stack');
206
+ // A demo slide would put a live page — server round-trips and all — in
207
+ // every row. A framed blank stands in; the header still names the demo.
208
+ for (const frame of clone.querySelectorAll('iframe')) {
209
+ const shell = document.createElement('div');
210
+ shell.className = 'deck-outline-thumb-frame';
211
+ frame.replaceWith(shell);
212
+ }
213
+ // Cloned ids (headings, mermaid SVGs) would duplicate the document's.
214
+ for (const el of clone.querySelectorAll('[id]')) el.removeAttribute('id');
215
+ clone.removeAttribute('id');
216
+ const stage = document.createElement('span');
217
+ stage.className = 'deck-outline-thumb reveal';
218
+ stage.setAttribute('aria-hidden', 'true');
219
+ const slides = document.createElement('span');
220
+ slides.className = 'slides';
221
+ slides.append(clone);
222
+ stage.append(slides);
223
+ return stage;
224
+ };
225
+
226
+ let rows = [];
227
+
228
+ const render = () => {
229
+ rows = rowsFor(deck);
230
+ list.replaceChildren(
231
+ ...rows.map((row, index) => {
232
+ if (row.kind === 'section') {
233
+ const header = document.createElement('h2');
234
+ header.textContent = row.label;
235
+ return header;
236
+ }
237
+ const item = document.createElement('button');
238
+ item.type = 'button';
239
+ item.className = 'deck-outline-item';
240
+ item.dataset.row = index;
241
+ item.dataset.depth = Math.min(row.depth, 2);
242
+ if (row.divider) item.classList.add('is-divider');
243
+ if (row.vertical) item.classList.add('is-vertical');
244
+ if (view === 'preview') {
245
+ item.innerHTML = `<span class="deck-outline-n">${row.number}</span>`;
246
+ item.prepend(thumbFor(row.slide));
247
+ // No room for the title in the row; it moves to the tooltip.
248
+ item.title = titleOf(row.slide);
249
+ } else {
250
+ item.innerHTML = `<span class="deck-outline-title"></span><span class="deck-outline-n">${row.number}</span>`;
251
+ item.querySelector('.deck-outline-title').textContent = titleOf(row.slide);
252
+ }
253
+ return item;
254
+ }),
255
+ );
256
+ syncThumbScale();
257
+ };
258
+
259
+ const applyView = () => {
260
+ panel.dataset.view = view;
261
+ const toList = view === 'preview';
262
+ viewButton.innerHTML = toList ? LIST_ICON : IMAGE_ICON;
263
+ viewButton.title = toList ? 'Title list' : 'Slide previews';
264
+ viewButton.setAttribute('aria-label', toList ? 'Show title list' : 'Show slide previews');
265
+ };
266
+ applyView();
267
+
268
+ const setView = (next) => {
269
+ if (next === view) return;
270
+ view = next;
271
+ applyView();
272
+ render();
273
+ markCurrent();
274
+ scrollToCurrent();
275
+ try {
276
+ localStorage.setItem(VIEW_KEY, view);
277
+ } catch {
278
+ /* not persisted, still switched */
279
+ }
280
+ };
281
+
282
+ viewButton.addEventListener('click', () => setView(view === 'list' ? 'preview' : 'list'));
283
+ window.addEventListener('resize', syncThumbScale);
284
+
285
+ const currentItem = () => {
286
+ const current = deck.getCurrentSlide();
287
+ return [...list.querySelectorAll('.deck-outline-item')].find(
288
+ (item) => rows[item.dataset.row].slide === current,
289
+ );
290
+ };
291
+
292
+ const scrollToCurrent = () => currentItem()?.scrollIntoView({ block: 'nearest' });
293
+
294
+ const markCurrent = () => {
295
+ const item = currentItem();
296
+ for (const other of list.querySelectorAll('.is-current')) other.classList.remove('is-current');
297
+ if (!item) return;
298
+ item.classList.add('is-current');
299
+ if (open) item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
300
+ };
301
+
302
+ list.addEventListener('click', (event) => {
303
+ const item = event.target.closest('.deck-outline-item');
304
+ if (!item) return;
305
+ const { h, v } = deck.getIndices(rows[item.dataset.row].slide);
306
+ deck.slide(h, v);
307
+ // Clicked with the mouse, the button would keep the focus and swallow the
308
+ // next Space as a re-click. Activated from the keyboard (`detail === 0`)
309
+ // the focus is the only place the caller has, so it stays.
310
+ if (event.detail) item.blur();
311
+ });
312
+
313
+ button.addEventListener('click', () => setOpen(!open));
314
+ panel.querySelector('.deck-outline-close').addEventListener('click', () => setOpen(false));
315
+
316
+ // The bare form binds the key without listing it: a descriptor here would
317
+ // put the row in the plugin table at the foot of the help overlay, and it
318
+ // belongs with the built-ins — see the `ready` handler below.
319
+ // Blacked out, the key would toggle a panel nobody can see — and `V` is one
320
+ // of the keys reveal lets through while paused, so it gets here. Leave the
321
+ // screen black: `B` and `.` are what brings it back.
322
+ deck.addKeyBinding(KEY_CODE, () => {
323
+ if (!deck.isPaused()) setOpen(!open);
324
+ });
325
+ // Escape is reveal's own overview toggle, and it binds before a plugin can.
326
+ // Capture phase gets there first — but only to take Escape back while the
327
+ // panel is open, so the key still opens the overview the rest of the time.
328
+ document.addEventListener(
329
+ 'keydown',
330
+ (event) => {
331
+ if (event.key !== 'Escape' || !open) return;
332
+ setOpen(false);
333
+ event.stopPropagation();
334
+ event.preventDefault();
335
+ },
336
+ true,
337
+ );
338
+
339
+ // Titles are read off the rendered slides, so the list can only be built
340
+ // once the markdown plugin has run — which `ready` is the signal for.
341
+ deck.on('ready', () => {
342
+ render();
343
+ markCurrent();
344
+ // Not before: the built-in shortcuts are only in the map once reveal has
345
+ // configured itself, and there would be no overview row to sit under.
346
+ listShortcutAfter(deck, HELP_ROW);
347
+ });
348
+ deck.on('slidechanged', markCurrent);
349
+
350
+ panel.inert = true;
351
+ },
352
+ });
353
+
354
+ export default RevealOutline;
@@ -0,0 +1,53 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The help overlay's table (`?`), edited.
3
+ //
4
+ // Reveal keeps its own shortcuts in one insertion-ordered map and renders the
5
+ // plugins' key bindings as a second, separate table under it. That is the right
6
+ // default — but not for a key that belongs beside a built-in rather than under
7
+ // a heading of its own, and not for a key a plugin takes over from reveal,
8
+ // which would leave the built-in row promising something it no longer does.
9
+ //
10
+ // Both cases are the same edit: the map has to be rebuilt, because emptying and
11
+ // refilling it is the only way to choose where a row lands. It lives here so
12
+ // that neither plugin has to carry the trick.
13
+ //
14
+ // Nothing in it is load-bearing: a reveal that stopped handing the map out
15
+ // leaves the overlay as reveal wrote it, and every key still works.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const mapOf = (deck) => deck.keyboard?.getShortcuts?.() ?? null;
19
+
20
+ // The map is live, so this reorders the real thing rather than a copy of it.
21
+ const rewrite = (shortcuts, rows) => {
22
+ for (const key of Object.keys(shortcuts)) delete shortcuts[key];
23
+ Object.assign(shortcuts, Object.fromEntries(rows));
24
+ };
25
+
26
+ // Reveal writes a row's keys as one string: `P , Shift SPACE`.
27
+ const SEPARATOR = ' , ';
28
+
29
+ // One row for `key`, in the place right below the first row whose action `after`
30
+ // matches — or at the end, if the deck is configured without that one.
31
+ export const listShortcutAfter = (deck, { key, action, after }) => {
32
+ const shortcuts = mapOf(deck);
33
+ if (!shortcuts) return;
34
+ const rows = Object.entries(shortcuts).filter(([keys]) => keys !== key);
35
+ const at = rows.findIndex(([, text]) => after.test(text));
36
+ rows.splice(at < 0 ? rows.length : at + 1, 0, [key, action]);
37
+ rewrite(shortcuts, rows);
38
+ };
39
+
40
+ // Take `key` out of the built-in row `from` matches, leaving the other keys in
41
+ // it. A row that had no other key goes with it.
42
+ export const unlistShortcut = (deck, { key, from }) => {
43
+ const shortcuts = mapOf(deck);
44
+ if (!shortcuts) return;
45
+ const rows = Object.entries(shortcuts)
46
+ .map(([keys, text]) => {
47
+ if (!from.test(text)) return [keys, text];
48
+ const kept = keys.split(',').map((one) => one.trim()).filter((one) => one !== key);
49
+ return [kept.join(SEPARATOR), text];
50
+ })
51
+ .filter(([keys]) => keys);
52
+ rewrite(shortcuts, rows);
53
+ };