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,458 @@
1
+ // ---------------------------------------------------------------------------
2
+ // RevealAnnotate — draw on the slide.
3
+ //
4
+ // A reveal.js plugin: `init()` gets the deck instance, so this file owns the
5
+ // whole feature — its chrome, its canvas and its key bindings — and index.html
6
+ // stays a page of slides. Register it in the `plugins:` array like any other.
7
+ //
8
+ // The ink goes on one full-viewport canvas in screen pixels, which keeps it
9
+ // clear of the CSS transform reveal scales a slide with: the pen follows the
10
+ // pointer, not the slide's coordinate space.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { unlistShortcut } from './shortcuts.js';
14
+
15
+ // The pen's palette. It lives here rather than in theme.css because it is a
16
+ // list the toolbar has to enumerate, not a token the theme swaps: `glow` is the
17
+ // halo, `core` the bright thread down the middle of it. JS hands the selected
18
+ // one back to CSS as `--pen`, which is what colours the armed button.
19
+ const COLOURS = [
20
+ { label: 'Orange', glow: '#ff8c1a', core: '#ffd6a8' },
21
+ { label: 'Green', glow: '#35d76f', core: '#c9f7dd' },
22
+ { label: 'Fire red', glow: '#ff3b20', core: '#ffc7bb' },
23
+ { label: 'Sky', glow: '#4cc9ff', core: '#d3efff' },
24
+ { label: 'White', glow: '#ffffff', core: '#ffffff' },
25
+ { label: 'Black', glow: '#000000', core: '#000000' },
26
+ ];
27
+
28
+ // A released stroke rubs itself out from its oldest point towards its newest.
29
+ // FADE_MS is how long that takes end to end, FADE_TAIL how much of the stroke
30
+ // the erase front softens as it passes — at 0 it would be a hard wipe.
31
+ const FADE_MS = 1600;
32
+ const FADE_TAIL = 0.35;
33
+
34
+ // Softness comes from the input, not the curve: resample the pointer onto
35
+ // points that are far enough apart to be signal, and pull each one back towards
36
+ // the last so the hand's own tremor never reaches the canvas.
37
+ const MIN_STEP = 3; // px between recorded points; anything closer is jitter
38
+ const SMOOTHING = 0.45; // how far each raw sample is dragged towards the last
39
+
40
+ // Catmull-Rom through the points, as one cubic per segment. 1/6 is the tension
41
+ // that makes the spline pass through each point with a continuous tangent —
42
+ // higher bulges the curve out past them.
43
+ const TENSION = 1 / 6;
44
+
45
+ const WIDTH = 3.4; // px, the bright core of the line
46
+
47
+ // Two layers, each the same path stroked at a few widths: a soft halo, then the
48
+ // core on top of it. Within a layer the passes stack additively, hence the low
49
+ // alphas. Cheaper than a shadowBlur per segment.
50
+ const LAYERS = [
51
+ { ink: 'glow', passes: [{ width: 7, alpha: 0.07 }, { width: 3.2, alpha: 0.16 }] },
52
+ { ink: 'core', passes: [{ width: 1, alpha: 1 }] },
53
+ ];
54
+
55
+ const MAX_WIDTH = WIDTH * Math.max(...LAYERS.flatMap((l) => l.passes.map((p) => p.width)));
56
+
57
+ // Alpha is quantised so consecutive segments sharing a value can go down as one
58
+ // path: a stroke is hundreds of segments, but the fade only varies across the
59
+ // narrow band the erase front is crossing.
60
+ const QUANTA = 24;
61
+
62
+ // `P` is reveal's own previous-slide key, and a plugin binding wins over it —
63
+ // which is the point: on stage the pen is reached for far more often than a key
64
+ // ← and Shift+Space already do. Reveal's own row in the help overlay still
65
+ // lists it, though, so it has to be corrected.
66
+ const KEY_CODE = 80;
67
+ const HELP_TAKEN_FROM = { key: 'P', from: /previous/i };
68
+
69
+ const PEN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
70
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
71
+ <path d="M15.2 4.6l4.2 4.2" /><path d="M4 20l4.7-1L19.8 7.9a2 2 0 0 0-2.8-2.8L5.9 16.3 4 20z" />
72
+ </svg>`;
73
+
74
+ // Black text on a light chip, light text on a dark one — the armed pen button
75
+ // wears the ink's own colour, and two of the six are the extremes.
76
+ const readable = (hex) => {
77
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
78
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 ? '#17110a' : '#f2f5f9';
79
+ };
80
+
81
+ // The two control points of the cubic running from `points[i]` to `points[i+1]`,
82
+ // each aimed along the line between that point's own neighbours. Consecutive
83
+ // segments therefore leave and arrive on the same tangent, which is what lets
84
+ // the fade cut the stroke into pieces without the seams showing.
85
+ const controls = (points, i) => {
86
+ const before = points[Math.max(i - 1, 0)];
87
+ const from = points[i];
88
+ const to = points[i + 1];
89
+ const after = points[Math.min(i + 2, points.length - 1)];
90
+ return [
91
+ from.x + (to.x - before.x) * TENSION,
92
+ from.y + (to.y - before.y) * TENSION,
93
+ to.x - (after.x - from.x) * TENSION,
94
+ to.y - (after.y - from.y) * TENSION,
95
+ ];
96
+ };
97
+
98
+ // How opaque a stroke is `u` of the way along itself (0 oldest, 1 newest), when
99
+ // it is `progress` of the way through its fade. `null` is a stroke still being
100
+ // drawn — those do not fade at all.
101
+ const alphaAt = (u, progress) => {
102
+ if (progress === null) return 1;
103
+ const front = progress * (1 + FADE_TAIL) - FADE_TAIL;
104
+ return Math.min(1, Math.max(0, (u - front) / FADE_TAIL));
105
+ };
106
+
107
+ const quantise = (alpha) => Math.round(alpha * QUANTA) / QUANTA;
108
+
109
+ const RevealAnnotate = () => ({
110
+ id: 'annotate',
111
+
112
+ init(deck) {
113
+ // ---- chrome ---------------------------------------------------------
114
+ // Fixed-position overlays, so they live on <body> next to the deck's other
115
+ // corners rather than inside `.reveal`, whose transforms they must escape.
116
+ const canvas = document.createElement('canvas');
117
+ canvas.id = 'deck-ink';
118
+ canvas.setAttribute('aria-hidden', 'true');
119
+
120
+ const toolbar = document.createElement('div');
121
+ toolbar.id = 'deck-tools';
122
+ toolbar.setAttribute('role', 'toolbar');
123
+ toolbar.setAttribute('aria-label', 'Slide tools');
124
+ toolbar.innerHTML = `
125
+ <button id="tool-pen" class="deck-tool" type="button" aria-pressed="false"
126
+ title="Pen — draw on the slide (P)" aria-label="Pen">${PEN_ICON}</button>
127
+ <div id="deck-swatches" role="radiogroup" aria-label="Pen colour" hidden>
128
+ ${COLOURS.map(
129
+ (colour, i) => `<button class="deck-swatch" type="button" role="radio"
130
+ aria-checked="${i === 0}" data-colour="${i}" style="--ink:${colour.glow}"
131
+ title="${colour.label}" aria-label="${colour.label}"></button>`,
132
+ ).join('')}
133
+ </div>
134
+ `;
135
+
136
+ document.body.append(canvas, toolbar);
137
+ const penButton = toolbar.querySelector('#tool-pen');
138
+ const swatches = toolbar.querySelector('#deck-swatches');
139
+ const ctx = canvas.getContext('2d');
140
+
141
+ // ---- the canvas -----------------------------------------------------
142
+
143
+ let width = 0;
144
+ let height = 0;
145
+ let dpr = 1;
146
+ // Built on first use: a deck nobody draws on should not be paying for a
147
+ // second full-screen canvas. See `onLayer` for what it is for.
148
+ let layer = null;
149
+ let layerCtx = null;
150
+
151
+ const resize = () => {
152
+ // Past 2x the extra pixels are free to nobody: this canvas is repainted
153
+ // whole on every frame of a fade, and the ink is a soft glow anyway.
154
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
155
+ width = window.innerWidth;
156
+ height = window.innerHeight;
157
+ // Assigning width/height resets the context, transform included — so
158
+ // scale after, not before, and let the render repaint what was on it.
159
+ canvas.width = Math.round(width * dpr);
160
+ canvas.height = Math.round(height * dpr);
161
+ canvas.style.width = `${width}px`;
162
+ canvas.style.height = `${height}px`;
163
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
164
+ layer = null;
165
+ schedule();
166
+ };
167
+
168
+ // Build a shape on its own layer, then tint it. Every colour has to behave
169
+ // the same, black included, and that rules out drawing an ink-coloured line
170
+ // straight onto the slide: the seams between the runs of a fade only
171
+ // disappear under `lighter`, which is additive and therefore blind to dark
172
+ // ink. So the shape is laid down in white — where `lighter` adds the two
173
+ // halves of a seam pixel back into a whole — and `source-in` then replaces
174
+ // that white with the ink, keeping the coverage it just worked out.
175
+ //
176
+ // Everything is clipped to the stroke's own box: `source-in` clears whatever
177
+ // the source does not cover, and unclipped that is the whole canvas.
178
+ const onLayer = (box, tint, drawShape) => {
179
+ if (!layer) {
180
+ layer = document.createElement('canvas');
181
+ layer.width = canvas.width;
182
+ layer.height = canvas.height;
183
+ layerCtx = layer.getContext('2d');
184
+ }
185
+ layerCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
186
+ layerCtx.save();
187
+ layerCtx.beginPath();
188
+ layerCtx.rect(box.x, box.y, box.w, box.h);
189
+ layerCtx.clip();
190
+ layerCtx.clearRect(box.x, box.y, box.w, box.h);
191
+ layerCtx.globalCompositeOperation = 'lighter';
192
+ layerCtx.lineJoin = 'round';
193
+ drawShape(layerCtx);
194
+ layerCtx.globalCompositeOperation = 'source-in';
195
+ layerCtx.fillStyle = tint;
196
+ layerCtx.fillRect(box.x, box.y, box.w, box.h);
197
+ layerCtx.restore();
198
+
199
+ ctx.drawImage(
200
+ layer,
201
+ box.x * dpr, box.y * dpr, box.w * dpr, box.h * dpr,
202
+ box.x, box.y, box.w, box.h,
203
+ );
204
+ };
205
+
206
+ // ---- strokes --------------------------------------------------------
207
+ // A stroke is its points, the colour it was drawn in, and the moment the
208
+ // pointer left the slide: `releasedAt: null` is one still under the pen.
209
+
210
+ const strokes = [];
211
+ let drawing = null;
212
+ let frame = 0;
213
+
214
+ const addPoint = (event) => {
215
+ const points = drawing.points;
216
+ const previous = points[points.length - 1];
217
+ let { clientX: x, clientY: y } = event;
218
+
219
+ if (previous) {
220
+ x = previous.x + (x - previous.x) * (1 - SMOOTHING);
221
+ y = previous.y + (y - previous.y) * (1 - SMOOTHING);
222
+ if (Math.hypot(x - previous.x, y - previous.y) < MIN_STEP) return;
223
+ }
224
+ points.push({ x, y });
225
+ };
226
+
227
+ // The slab of canvas a stroke can reach, clamped to the viewport. Padded by
228
+ // half the widest pass, plus a little for the curve's overshoot between
229
+ // points.
230
+ const boundsOf = (points) => {
231
+ let minX = Infinity;
232
+ let minY = Infinity;
233
+ let maxX = -Infinity;
234
+ let maxY = -Infinity;
235
+ for (const { x, y } of points) {
236
+ if (x < minX) minX = x;
237
+ if (y < minY) minY = y;
238
+ if (x > maxX) maxX = x;
239
+ if (y > maxY) maxY = y;
240
+ }
241
+ const pad = MAX_WIDTH / 2 + 3;
242
+ const x = Math.max(0, Math.floor(minX - pad));
243
+ const y = Math.max(0, Math.floor(minY - pad));
244
+ return {
245
+ x,
246
+ y,
247
+ w: Math.min(width, Math.ceil(maxX + pad)) - x,
248
+ h: Math.min(height, Math.ceil(maxY + pad)) - y,
249
+ };
250
+ };
251
+
252
+ // Cut the stroke into runs of segments that share a quantised alpha. Runs
253
+ // meet end to end on a shared point and a shared tangent, and are stroked
254
+ // with a butt cap: a round cap would lay a disc over the next run's start,
255
+ // and those are the beads that crawl along a line as it fades.
256
+ const runsOf = (stroke, progress) => {
257
+ const points = stroke.points;
258
+ const segments = points.length - 1;
259
+ const runs = [];
260
+
261
+ let path = null;
262
+ let alpha = -1;
263
+ for (let i = 0; i < segments; i++) {
264
+ // A one-segment stroke has no oldest end to start from: fade it whole.
265
+ const next = quantise(alphaAt(segments === 1 ? 0.5 : i / (segments - 1), progress));
266
+ if (next !== alpha) {
267
+ alpha = next;
268
+ path = null;
269
+ if (alpha > 0) {
270
+ path = new Path2D();
271
+ path.moveTo(points[i].x, points[i].y);
272
+ runs.push({ path, alpha, round: false });
273
+ }
274
+ }
275
+ if (!path) continue;
276
+ path.bezierCurveTo(...controls(points, i), points[i + 1].x, points[i + 1].y);
277
+ }
278
+ // `lineCap` applies to both ends of a path, so a run can only be rounded
279
+ // off when it has no seam to spoil — which is every stroke that is not
280
+ // mid-fade, the pen's own line included.
281
+ if (runs.length === 1) runs[0].round = true;
282
+ return runs;
283
+ };
284
+
285
+ // Returns false once the stroke has faded out and can be dropped.
286
+ const paint = (stroke, now) => {
287
+ const progress = stroke.releasedAt === null ? null : (now - stroke.releasedAt) / FADE_MS;
288
+ if (progress !== null && progress >= 1) return false;
289
+
290
+ const box = boundsOf(stroke.points);
291
+ if (box.w <= 0 || box.h <= 0) return true;
292
+
293
+ // Pressed without moving: the pen still left a mark.
294
+ if (stroke.points.length < 2) {
295
+ const alpha = progress === null ? 1 : 1 - progress;
296
+ const [point] = stroke.points;
297
+ for (const { ink, passes } of LAYERS) {
298
+ onLayer(box, stroke.ink[ink], (c) => {
299
+ for (const pass of passes) {
300
+ c.beginPath();
301
+ c.arc(point.x, point.y, (WIDTH * pass.width) / 2, 0, Math.PI * 2);
302
+ c.fillStyle = `rgba(255, 255, 255, ${alpha * pass.alpha})`;
303
+ c.fill();
304
+ }
305
+ });
306
+ }
307
+ return true;
308
+ }
309
+
310
+ const runs = runsOf(stroke, progress);
311
+ for (const { ink, passes } of LAYERS) {
312
+ onLayer(box, stroke.ink[ink], (c) => {
313
+ for (const pass of passes) {
314
+ c.lineWidth = WIDTH * pass.width;
315
+ for (const run of runs) {
316
+ c.lineCap = run.round ? 'round' : 'butt';
317
+ c.strokeStyle = `rgba(255, 255, 255, ${run.alpha * pass.alpha})`;
318
+ c.stroke(run.path);
319
+ }
320
+ }
321
+ });
322
+ }
323
+ return true;
324
+ };
325
+
326
+ const render = () => {
327
+ frame = 0;
328
+ const now = performance.now();
329
+
330
+ ctx.globalCompositeOperation = 'source-over';
331
+ ctx.clearRect(0, 0, width, height);
332
+
333
+ for (let i = strokes.length - 1; i >= 0; i--) {
334
+ if (!paint(strokes[i], now)) strokes.splice(i, 1);
335
+ }
336
+ // Only a fading stroke needs the next frame. A live one changes when a
337
+ // point is added and not before, so a pen held still costs nothing —
338
+ // without this the loop repaints a full-screen canvas forever.
339
+ if (strokes.some((stroke) => stroke.releasedAt !== null)) schedule();
340
+ };
341
+
342
+ const schedule = () => {
343
+ frame ||= requestAnimationFrame(render);
344
+ };
345
+
346
+ const clear = () => {
347
+ drawing = null;
348
+ strokes.length = 0;
349
+ ctx.clearRect(0, 0, width, height);
350
+ };
351
+
352
+ // ---- the pen --------------------------------------------------------
353
+
354
+ let penOn = false;
355
+ let ink = COLOURS[0];
356
+
357
+ const setInk = (colour) => {
358
+ ink = colour;
359
+ // Handed back to CSS: the armed button wears the colour it draws in.
360
+ toolbar.style.setProperty('--pen', colour.glow);
361
+ toolbar.style.setProperty('--pen-ink', readable(colour.glow));
362
+ for (const button of swatches.children) {
363
+ button.setAttribute('aria-checked', String(COLOURS[button.dataset.colour] === colour));
364
+ }
365
+ };
366
+
367
+ const setPen = (on) => {
368
+ penOn = on;
369
+ penButton.setAttribute('aria-pressed', String(on));
370
+ toolbar.classList.toggle('is-armed', on);
371
+ // The colours are only worth showing while there is a pen to apply them
372
+ // to, which makes arming it the one gesture that opens them.
373
+ swatches.hidden = !on;
374
+ // Only an armed canvas takes the pointer; the rest of the time clicks
375
+ // fall through to the slide underneath.
376
+ canvas.classList.toggle('is-live', on);
377
+ if (on) document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: 'pen' }));
378
+ else clear();
379
+ };
380
+
381
+ // Two tools, one pointer: arming either disarms the other. The handshake
382
+ // is a DOM event rather than an import either way round, so each plugin
383
+ // works alone.
384
+ document.addEventListener('deck:tool-armed', (event) => {
385
+ if (event.detail !== 'pen' && penOn) setPen(false);
386
+ });
387
+
388
+ canvas.addEventListener('pointerdown', (event) => {
389
+ if (!penOn || event.button !== 0) return;
390
+ canvas.setPointerCapture(event.pointerId);
391
+ // The colour is caught at the down-stroke, so a line already fading keeps
392
+ // the one it was drawn in when you pick another.
393
+ drawing = { id: event.pointerId, points: [], releasedAt: null, ink };
394
+ strokes.push(drawing);
395
+ addPoint(event);
396
+ schedule();
397
+ });
398
+
399
+ canvas.addEventListener('pointermove', (event) => {
400
+ if (!drawing || event.pointerId !== drawing.id) return;
401
+ // A trackpad reports faster than the display refreshes, and the samples
402
+ // in between are what keeps a quick curve smooth instead of faceted.
403
+ // The list comes back empty where there is nothing to coalesce, which is
404
+ // still a move worth recording — hence the event itself as the fallback.
405
+ const samples = event.getCoalescedEvents?.() ?? [];
406
+ for (const sample of samples.length ? samples : [event]) addPoint(sample);
407
+ schedule();
408
+ });
409
+
410
+ const release = (event) => {
411
+ if (!drawing || event.pointerId !== drawing.id) return;
412
+ drawing.releasedAt = performance.now();
413
+ drawing = null;
414
+ schedule();
415
+ };
416
+ canvas.addEventListener('pointerup', release);
417
+ canvas.addEventListener('pointercancel', release);
418
+
419
+ penButton.addEventListener('click', () => setPen(!penOn));
420
+ swatches.addEventListener('click', (event) => {
421
+ const button = event.target.closest('.deck-swatch');
422
+ if (button) setInk(COLOURS[button.dataset.colour]);
423
+ });
424
+
425
+ // `addKeyBinding` with a descriptor binds the key and lists it in reveal's
426
+ // own help overlay, which `registerKeyboardShortcut` alone would not do.
427
+ deck.addKeyBinding({ keyCode: KEY_CODE, key: 'P', description: 'Toggle the pen' }, () =>
428
+ setPen(!penOn),
429
+ );
430
+ // Escape is reveal's own overview toggle, and it binds before a plugin can.
431
+ // Capture phase gets there first — but only to take Escape back while the
432
+ // pen is armed, so the key still opens the overview the rest of the time.
433
+ document.addEventListener(
434
+ 'keydown',
435
+ (event) => {
436
+ if (event.key !== 'Escape' || !penOn) return;
437
+ setPen(false);
438
+ event.stopPropagation();
439
+ event.preventDefault();
440
+ },
441
+ true,
442
+ );
443
+
444
+ // Only once reveal has configured itself is there a row to correct.
445
+ deck.on('ready', () => unlistShortcut(deck, HELP_TAKEN_FROM));
446
+
447
+ // A new slide is a clean sheet — including a line still under the pointer.
448
+ deck.on('slidechanged', clear);
449
+ // The overview is a different surface; drawing over it would annotate nothing.
450
+ deck.on('overviewshown', () => setPen(false));
451
+
452
+ window.addEventListener('resize', resize);
453
+ setInk(ink);
454
+ resize();
455
+ },
456
+ });
457
+
458
+ export default RevealAnnotate;