fb-slides 0.3.0 → 0.4.0

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
@@ -129,6 +129,8 @@ Note: a bad description is the most common reason a tool never gets used.
129
129
  | --- | --- |
130
130
  | `→` / `Space` | next step (fragments included) |
131
131
  | `P` | the pen — draw on the slide, the ink fades on its own |
132
+ | `A` | the arrow — click the tail, then the tip; same colours as the pen |
133
+ | `C` | the pointer — replaces the cursor: a glowing halo, a dart aimed at the centre, or the normal mouse |
132
134
  | `Shift` (tap) | the spotlight — drag a rectangle, the rest of the slide dims and blurs |
133
135
  | `S` | speaker view — notes, timer, next slide |
134
136
  | `Esc` / `O` | overview — the slides as a grid |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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": [
@@ -5,6 +5,10 @@
5
5
  // whole feature — its chrome, its canvas and its key bindings — and index.html
6
6
  // stays a page of slides. Register it in the `plugins:` array like any other.
7
7
  //
8
+ // Two tools share everything here: the pen, and the arrow — click where it
9
+ // starts, click (or release a drag) where it points. Same canvas, same
10
+ // palette, same hold-then-fade; an arrow is just one more kind of stroke.
11
+ //
8
12
  // The ink goes on one full-viewport canvas in screen pixels, which keeps it
9
13
  // clear of the CSS transform reveal scales a slide with: the pen follows the
10
14
  // pointer, not the slide's coordinate space.
@@ -46,6 +50,14 @@ const TENSION = 1 / 6;
46
50
 
47
51
  const WIDTH = 3.4; // px, the bright core of the line
48
52
 
53
+ // The arrow's head: how far back along the shaft the wings reach, and how far
54
+ // they open off it. A short arrow shrinks its head rather than being all head.
55
+ const HEAD_LEN = 48;
56
+ const HEAD_SPREAD = 0.55; // radians
57
+ // Under this, a press-and-release is a click placing the tail, not a whole
58
+ // arrow: the tip then follows the pointer until the second click.
59
+ const MIN_ARROW = 12;
60
+
49
61
  // Two layers, each the same path stroked at a few widths: a soft halo, then the
50
62
  // core on top of it. Within a layer the passes stack additively, hence the low
51
63
  // alphas. Cheaper than a shadowBlur per segment.
@@ -64,8 +76,10 @@ const QUANTA = 24;
64
76
  // `P` is reveal's own previous-slide key, and a plugin binding wins over it —
65
77
  // which is the point: on stage the pen is reached for far more often than a key
66
78
  // ← and Shift+Space already do. Reveal's own row in the help overlay still
67
- // lists it, though, so it has to be corrected.
68
- const KEY_CODE = 80;
79
+ // lists it, though, so it has to be corrected. `A` is reveal's auto-slide
80
+ // toggle, idle in a deck that does not auto-advance — the arrow takes it.
81
+ const PEN_KEY_CODE = 80;
82
+ const ARROW_KEY_CODE = 65;
69
83
  const HELP_TAKEN_FROM = { key: 'P', from: /previous/i };
70
84
 
71
85
  const PEN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
@@ -73,6 +87,11 @@ const PEN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" str
73
87
  <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" />
74
88
  </svg>`;
75
89
 
90
+ const ARROW_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
91
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
92
+ <path d="M5 19L19 5" /><path d="M9.5 5H19v9.5" />
93
+ </svg>`;
94
+
76
95
  // Black text on a light chip, light text on a dark one — the armed pen button
77
96
  // wears the ink's own colour, and two of the six are the extremes.
78
97
  const readable = (hex) => {
@@ -126,7 +145,9 @@ const RevealAnnotate = () => ({
126
145
  toolbar.innerHTML = `
127
146
  <button id="tool-pen" class="deck-tool" type="button" aria-pressed="false"
128
147
  title="Pen — draw on the slide (P)" aria-label="Pen">${PEN_ICON}</button>
129
- <div id="deck-swatches" role="radiogroup" aria-label="Pen colour" hidden>
148
+ <button id="tool-arrow" class="deck-tool" type="button" aria-pressed="false"
149
+ title="Arrow — click the tail, then the tip (A)" aria-label="Arrow">${ARROW_ICON}</button>
150
+ <div id="deck-swatches" role="radiogroup" aria-label="Ink colour" hidden>
130
151
  ${COLOURS.map(
131
152
  (colour, i) => `<button class="deck-swatch" type="button" role="radio"
132
153
  aria-checked="${i === 0}" data-colour="${i}" style="--ink:${colour.glow}"
@@ -137,6 +158,7 @@ const RevealAnnotate = () => ({
137
158
 
138
159
  document.body.append(canvas, toolbar);
139
160
  const penButton = toolbar.querySelector('#tool-pen');
161
+ const arrowButton = toolbar.querySelector('#tool-arrow');
140
162
  const swatches = toolbar.querySelector('#deck-swatches');
141
163
  const ctx = canvas.getContext('2d');
142
164
 
@@ -208,9 +230,12 @@ const RevealAnnotate = () => ({
208
230
  // ---- strokes --------------------------------------------------------
209
231
  // A stroke is its points, the colour it was drawn in, and the moment the
210
232
  // pointer left the slide: `releasedAt: null` is one still under the pen.
233
+ // An arrow rides the same list with `kind: 'arrow'` and two points, `from`
234
+ // and `to` — it holds and fades exactly like a line of ink.
211
235
 
212
236
  const strokes = [];
213
- let drawing = null;
237
+ let drawing = null; // the pen's live stroke, or null
238
+ let placing = null; // an arrow whose tip still follows the pointer, or null
214
239
  let frame = 0;
215
240
  let holdTimer = 0;
216
241
 
@@ -285,6 +310,53 @@ const RevealAnnotate = () => ({
285
310
  return runs;
286
311
  };
287
312
 
313
+ // An arrow has no oldest end worth erasing first: it fades out whole, the
314
+ // shaft and its head together. The shaft ends where the head begins, so
315
+ // the glow's rounded cap never pokes past the tip.
316
+ const paintArrow = (stroke, progress) => {
317
+ const { from, to } = stroke;
318
+ const length = Math.hypot(to.x - from.x, to.y - from.y);
319
+ if (length < 1) return true;
320
+ const box = boundsOf([from, to]);
321
+ if (box.w <= 0 || box.h <= 0) return true;
322
+
323
+ const fade = progress === null ? 1 : 1 - progress;
324
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
325
+ const head = Math.min(HEAD_LEN, length * 0.6);
326
+ const wings = [HEAD_SPREAD, -HEAD_SPREAD].map((spread) => ({
327
+ x: to.x - head * Math.cos(angle + spread),
328
+ y: to.y - head * Math.sin(angle + spread),
329
+ }));
330
+ const base = {
331
+ x: to.x - head * 0.75 * Math.cos(angle),
332
+ y: to.y - head * 0.75 * Math.sin(angle),
333
+ };
334
+
335
+ for (const { ink, passes } of LAYERS) {
336
+ onLayer(box, stroke.ink[ink], (c) => {
337
+ c.lineCap = 'round';
338
+ for (const pass of passes) {
339
+ c.lineWidth = WIDTH * pass.width;
340
+ c.strokeStyle = c.fillStyle = `rgba(255, 255, 255, ${fade * pass.alpha})`;
341
+ c.beginPath();
342
+ c.moveTo(from.x, from.y);
343
+ c.lineTo(base.x, base.y);
344
+ c.stroke();
345
+ // The head is filled for its body and stroked for its glow: the
346
+ // stroke fattens it by the same width the shaft wears.
347
+ c.beginPath();
348
+ c.moveTo(to.x, to.y);
349
+ c.lineTo(wings[0].x, wings[0].y);
350
+ c.lineTo(wings[1].x, wings[1].y);
351
+ c.closePath();
352
+ c.fill();
353
+ c.stroke();
354
+ }
355
+ });
356
+ }
357
+ return true;
358
+ };
359
+
288
360
  // Returns false once the stroke has faded out and can be dropped.
289
361
  const paint = (stroke, now) => {
290
362
  // Clamped at 0 through the hold: a stroke waiting its turn paints exactly
@@ -295,6 +367,8 @@ const RevealAnnotate = () => ({
295
367
  : Math.max(0, (now - stroke.releasedAt - HOLD_MS) / FADE_MS);
296
368
  if (progress !== null && progress >= 1) return false;
297
369
 
370
+ if (stroke.kind === 'arrow') return paintArrow(stroke, progress);
371
+
298
372
  const box = boundsOf(stroke.points);
299
373
  if (box.w <= 0 || box.h <= 0) return true;
300
374
 
@@ -360,16 +434,18 @@ const RevealAnnotate = () => ({
360
434
 
361
435
  const clear = () => {
362
436
  drawing = null;
437
+ placing = null;
363
438
  strokes.length = 0;
364
439
  clearTimeout(holdTimer);
365
440
  holdTimer = 0;
366
441
  ctx.clearRect(0, 0, width, height);
367
442
  };
368
443
 
369
- // ---- the pen --------------------------------------------------------
444
+ // ---- the tools ------------------------------------------------------
370
445
 
371
- let penOn = false;
446
+ let tool = null; // null, 'pen' or 'arrow'
372
447
  let ink = COLOURS[0];
448
+ const toolButtons = { pen: penButton, arrow: arrowButton };
373
449
 
374
450
  const setInk = (colour) => {
375
451
  ink = colour;
@@ -381,29 +457,60 @@ const RevealAnnotate = () => ({
381
457
  }
382
458
  };
383
459
 
384
- const setPen = (on) => {
385
- penOn = on;
386
- penButton.setAttribute('aria-pressed', String(on));
387
- toolbar.classList.toggle('is-armed', on);
388
- // The colours are only worth showing while there is a pen to apply them
389
- // to, which makes arming it the one gesture that opens them.
390
- swatches.hidden = !on;
460
+ const setTool = (next) => {
461
+ if (next === tool) return;
462
+ // Whatever was mid-gesture under the old tool does not survive the
463
+ // switch: a half-drawn line is released, a half-aimed arrow discarded.
464
+ if (drawing) {
465
+ drawing.releasedAt = performance.now();
466
+ drawing = null;
467
+ }
468
+ if (placing) {
469
+ strokes.splice(strokes.indexOf(placing), 1);
470
+ placing = null;
471
+ schedule();
472
+ }
473
+ tool = next;
474
+ for (const [name, button] of Object.entries(toolButtons)) {
475
+ button.setAttribute('aria-pressed', String(name === tool));
476
+ }
477
+ toolbar.classList.toggle('is-armed', tool !== null);
478
+ // The colours are only worth showing while there is a tool to apply them
479
+ // to, which makes arming one the gesture that opens them.
480
+ swatches.hidden = tool === null;
391
481
  // Only an armed canvas takes the pointer; the rest of the time clicks
392
482
  // fall through to the slide underneath.
393
- canvas.classList.toggle('is-live', on);
394
- if (on) document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: 'pen' }));
483
+ canvas.classList.toggle('is-live', tool !== null);
484
+ if (tool) document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: tool }));
395
485
  else clear();
396
486
  };
487
+ const toggleTool = (name) => setTool(tool === name ? null : name);
397
488
 
398
- // Two tools, one pointer: arming either disarms the other. The handshake
489
+ // Several tools, one pointer: arming any disarms the rest. The handshake
399
490
  // is a DOM event rather than an import either way round, so each plugin
400
- // works alone.
491
+ // works alone. Pen and arrow are both this plugin's, so only a foreign
492
+ // detail stands it down.
401
493
  document.addEventListener('deck:tool-armed', (event) => {
402
- if (event.detail !== 'pen' && penOn) setPen(false);
494
+ if (event.detail !== 'pen' && event.detail !== 'arrow' && tool) setTool(null);
403
495
  });
404
496
 
405
497
  canvas.addEventListener('pointerdown', (event) => {
406
- if (!penOn || event.button !== 0) return;
498
+ if (!tool || event.button !== 0) return;
499
+ if (tool === 'arrow') {
500
+ // Second click: the tip lands here. First click: the tail does, and
501
+ // the tip follows the pointer until the next one.
502
+ if (placing) {
503
+ placing.to = { x: event.clientX, y: event.clientY };
504
+ placing.releasedAt = performance.now();
505
+ placing = null;
506
+ } else {
507
+ const at = { x: event.clientX, y: event.clientY };
508
+ placing = { kind: 'arrow', from: at, to: { ...at }, releasedAt: null, ink };
509
+ strokes.push(placing);
510
+ }
511
+ schedule();
512
+ return;
513
+ }
407
514
  canvas.setPointerCapture(event.pointerId);
408
515
  // The colour is caught at the down-stroke, so a line already fading keeps
409
516
  // the one it was drawn in when you pick another.
@@ -414,6 +521,12 @@ const RevealAnnotate = () => ({
414
521
  });
415
522
 
416
523
  canvas.addEventListener('pointermove', (event) => {
524
+ // An arrow being aimed follows the bare pointer — no button held.
525
+ if (placing) {
526
+ placing.to = { x: event.clientX, y: event.clientY };
527
+ schedule();
528
+ return;
529
+ }
417
530
  if (!drawing || event.pointerId !== drawing.id) return;
418
531
  // A trackpad reports faster than the display refreshes, and the samples
419
532
  // in between are what keeps a quick curve smooth instead of faceted.
@@ -425,6 +538,19 @@ const RevealAnnotate = () => ({
425
538
  });
426
539
 
427
540
  const release = (event) => {
541
+ // A drag long enough to be deliberate is the whole gesture: down at the
542
+ // tail, up at the tip. Anything shorter was a click, and the arrow stays
543
+ // under the pointer waiting for the second one.
544
+ if (placing) {
545
+ const span = Math.hypot(event.clientX - placing.from.x, event.clientY - placing.from.y);
546
+ if (span >= MIN_ARROW) {
547
+ placing.to = { x: event.clientX, y: event.clientY };
548
+ placing.releasedAt = performance.now();
549
+ placing = null;
550
+ schedule();
551
+ }
552
+ return;
553
+ }
428
554
  if (!drawing || event.pointerId !== drawing.id) return;
429
555
  drawing.releasedAt = performance.now();
430
556
  drawing = null;
@@ -433,7 +559,8 @@ const RevealAnnotate = () => ({
433
559
  canvas.addEventListener('pointerup', release);
434
560
  canvas.addEventListener('pointercancel', release);
435
561
 
436
- penButton.addEventListener('click', () => setPen(!penOn));
562
+ penButton.addEventListener('click', () => toggleTool('pen'));
563
+ arrowButton.addEventListener('click', () => toggleTool('arrow'));
437
564
  swatches.addEventListener('click', (event) => {
438
565
  const button = event.target.closest('.deck-swatch');
439
566
  if (button) setInk(COLOURS[button.dataset.colour]);
@@ -441,19 +568,22 @@ const RevealAnnotate = () => ({
441
568
 
442
569
  // `addKeyBinding` with a descriptor binds the key and lists it in reveal's
443
570
  // own help overlay, which `registerKeyboardShortcut` alone would not do.
444
- deck.addKeyBinding({ keyCode: KEY_CODE, key: 'P', description: 'Toggle the pen' }, () =>
445
- setPen(!penOn),
571
+ deck.addKeyBinding({ keyCode: PEN_KEY_CODE, key: 'P', description: 'Toggle the pen' }, () =>
572
+ toggleTool('pen'),
573
+ );
574
+ deck.addKeyBinding({ keyCode: ARROW_KEY_CODE, key: 'A', description: 'Toggle the arrow' }, () =>
575
+ toggleTool('arrow'),
446
576
  );
447
- // Keys the pen borrows only while it is armed, taken in capture phase so
577
+ // Keys the tools borrow only while one is armed, taken in capture phase so
448
578
  // reveal's own bindings never see them: Escape (otherwise the overview
449
- // toggle) disarms it, and the number row picks its ink — 1 to 6, in the
579
+ // toggle) disarms it, and the number row picks the ink — 1 to 6, in the
450
580
  // order the swatches sit in the toolbar. The rest of the time both fall
451
581
  // through untouched.
452
582
  document.addEventListener(
453
583
  'keydown',
454
584
  (event) => {
455
- if (!penOn || event.metaKey || event.ctrlKey || event.altKey) return;
456
- if (event.key === 'Escape') setPen(false);
585
+ if (!tool || event.metaKey || event.ctrlKey || event.altKey) return;
586
+ if (event.key === 'Escape') setTool(null);
457
587
  else {
458
588
  const index = '123456'.indexOf(event.key);
459
589
  if (index === -1 || index >= COLOURS.length) return;
@@ -471,7 +601,7 @@ const RevealAnnotate = () => ({
471
601
  // A new slide is a clean sheet — including a line still under the pointer.
472
602
  deck.on('slidechanged', clear);
473
603
  // The overview is a different surface; drawing over it would annotate nothing.
474
- deck.on('overviewshown', () => setPen(false));
604
+ deck.on('overviewshown', () => setTool(null));
475
605
 
476
606
  window.addEventListener('resize', resize);
477
607
  setInk(ink);
package/runtime/deck.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import RevealAnnotate from './annotate.js';
2
2
  import RevealSpotlight from './spotlight.js';
3
+ import RevealPointer from './pointer.js';
3
4
  import RevealOutline from './outline.js';
4
5
 
5
6
  // ---------------------------------------------------------------------------
@@ -161,8 +162,9 @@ await Reveal.initialize({
161
162
  pdfSeparateFragments: false,
162
163
  // Anything the project wants to change, from `reveal:` in slides.config.js.
163
164
  ...(CFG.reveal ?? {}),
164
- // Spotlight after Annotate: it hangs its button on the toolbar the pen builds.
165
- plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealAnnotate(), RevealSpotlight(), RevealOutline()],
165
+ // Spotlight and Pointer after Annotate: they hang their buttons on the
166
+ // toolbar the pen builds.
167
+ plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealAnnotate(), RevealSpotlight(), RevealPointer(), RevealOutline()],
166
168
  });
167
169
 
168
170
  // ---------------------------------------------------------------------------
@@ -0,0 +1,249 @@
1
+ // ---------------------------------------------------------------------------
2
+ // RevealPointer — swap the mouse cursor for a stage pointer.
3
+ //
4
+ // A reveal.js plugin, built like spotlight.js: `init()` gets the deck, the file
5
+ // owns its chrome and key bindings, and index.html stays a page of slides.
6
+ //
7
+ // Armed, it hides the real cursor everywhere and hangs its own on the pointer
8
+ // instead. Three kinds, picked from a popover like the pen's swatches:
9
+ //
10
+ // halo — a bright dot inside a glowing ring; the ring trails the dot on a
11
+ // short elastic, which is what makes it read as alive rather than
12
+ // as a cursor theme.
13
+ // compass — a dart that always aims at the centre of the slide, so wherever
14
+ // the hand wanders the audience is pointed back at the deck.
15
+ // native — the system cursor again, without standing the tool down: a way
16
+ // back to a normal mouse that keeps the popover one keystroke away.
17
+ //
18
+ // Unlike the pen and the spotlight it survives a slide change: it is the
19
+ // cursor now, not a mark on one slide.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const KEY_CODE = 67; // C
23
+
24
+ const KINDS = [
25
+ {
26
+ id: 'halo',
27
+ label: 'Halo pointer',
28
+ icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
29
+ aria-hidden="true"><circle cx="12" cy="12" r="7.5" /><circle cx="12" cy="12" r="1.6"
30
+ fill="currentColor" stroke="none" /></svg>`,
31
+ },
32
+ {
33
+ id: 'compass',
34
+ label: 'Compass pointer — aims at the centre',
35
+ icon: `<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
36
+ <path d="M21 12L4.5 19.5 8.6 12 4.5 4.5z" /></svg>`,
37
+ },
38
+ {
39
+ id: 'native',
40
+ label: 'Normal cursor',
41
+ icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
42
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
43
+ <path d="M4 4l7.07 17 2.51-7.39L21 11.07z" /></svg>`,
44
+ },
45
+ ];
46
+
47
+ // How hard the halo's ring is pulled towards the dot each frame: 1 would pin
48
+ // it, lower trails further behind. The loop stops once it has caught up.
49
+ const CHASE = 0.28;
50
+ const SETTLED = 0.4; // px of remaining lag that counts as caught up
51
+
52
+ const POINTER_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
53
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
54
+ <circle cx="12" cy="12" r="7" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
55
+ </svg>`;
56
+
57
+ const RevealPointer = () => ({
58
+ id: 'pointer',
59
+
60
+ init(deck) {
61
+ // ---- chrome ---------------------------------------------------------
62
+ // Fixed overlays on <body>, outside `.reveal` and its transforms. The
63
+ // pointer rides above every other overlay — it stands in for the cursor,
64
+ // which is always on top of what it points at.
65
+ const layer = document.createElement('div');
66
+ layer.id = 'deck-pointer';
67
+ layer.setAttribute('aria-hidden', 'true');
68
+ layer.dataset.kind = KINDS[0].id;
69
+ layer.innerHTML = `
70
+ <div class="deck-pointer-halo"></div>
71
+ <div class="deck-pointer-dot"></div>
72
+ <svg class="deck-pointer-compass" viewBox="0 0 24 24" aria-hidden="true">
73
+ <path d="M21 12L4.5 19.5 8.6 12 4.5 4.5z" />
74
+ </svg>
75
+ `;
76
+ layer.hidden = true;
77
+ document.body.append(layer);
78
+ const halo = layer.querySelector('.deck-pointer-halo');
79
+ const dot = layer.querySelector('.deck-pointer-dot');
80
+ const compass = layer.querySelector('.deck-pointer-compass');
81
+
82
+ // The toolbar is annotate's; this plugin registers after it in deck.js and
83
+ // hangs its button there. A deck running without the pen still gets one.
84
+ let toolbar = document.querySelector('#deck-tools');
85
+ if (!toolbar) {
86
+ toolbar = document.createElement('div');
87
+ toolbar.id = 'deck-tools';
88
+ toolbar.setAttribute('role', 'toolbar');
89
+ toolbar.setAttribute('aria-label', 'Slide tools');
90
+ document.body.append(toolbar);
91
+ }
92
+ const button = document.createElement('button');
93
+ button.id = 'tool-pointer';
94
+ button.className = 'deck-tool';
95
+ button.type = 'button';
96
+ button.setAttribute('aria-pressed', 'false');
97
+ button.title = 'Pointer — replace the cursor (C)';
98
+ button.setAttribute('aria-label', 'Pointer');
99
+ button.innerHTML = POINTER_ICON;
100
+ toolbar.append(button);
101
+
102
+ // The kind picker, hanging under the toolbar exactly like the pen's
103
+ // swatches — the two are never open at once, since arming one tool
104
+ // disarms the other.
105
+ const picker = document.createElement('div');
106
+ picker.id = 'deck-pointer-kinds';
107
+ picker.setAttribute('role', 'radiogroup');
108
+ picker.setAttribute('aria-label', 'Pointer kind');
109
+ picker.hidden = true;
110
+ picker.innerHTML = KINDS.map(
111
+ (kind, i) => `<button class="deck-pointer-kind" type="button" role="radio"
112
+ aria-checked="${i === 0}" data-kind="${kind.id}"
113
+ title="${kind.label}" aria-label="${kind.label}">${kind.icon}</button>`,
114
+ ).join('');
115
+ toolbar.append(picker);
116
+
117
+ // ---- following the pointer ------------------------------------------
118
+
119
+ let armed = false;
120
+ let kind = KINDS[0].id;
121
+ let x = -100; // the pointer, in screen px; parked off-screen until a move
122
+ let y = -100;
123
+ let hx = -100; // where the halo's ring has got to on its way there
124
+ let hy = -100;
125
+ let frame = 0;
126
+
127
+ const aim = () => {
128
+ // The dart pivots on its own centre to face the middle of the viewport;
129
+ // right at the centre it just keeps its last heading.
130
+ const dx = window.innerWidth / 2 - x;
131
+ const dy = window.innerHeight / 2 - y;
132
+ if (dx || dy) compass.style.rotate = `${Math.atan2(dy, dx)}rad`;
133
+ };
134
+
135
+ const render = () => {
136
+ frame = 0;
137
+ hx += (x - hx) * CHASE;
138
+ hy += (y - hy) * CHASE;
139
+ // `translate` the individual property, not `transform`: the press effect
140
+ // scales through `transform` in CSS, and the two must not fight.
141
+ dot.style.translate = `${x}px ${y}px`;
142
+ compass.style.translate = `${x}px ${y}px`;
143
+ halo.style.translate = `${hx}px ${hy}px`;
144
+ if (Math.hypot(x - hx, y - hy) > SETTLED) schedule();
145
+ };
146
+
147
+ const schedule = () => {
148
+ frame ||= requestAnimationFrame(render);
149
+ };
150
+
151
+ const move = (event) => {
152
+ x = event.clientX;
153
+ y = event.clientY;
154
+ if (!armed || kind === 'native') return;
155
+ layer.hidden = false;
156
+ aim();
157
+ schedule();
158
+ };
159
+ document.addEventListener('pointermove', move);
160
+ // Falling off the window edge — or the window losing focus — leaves no
161
+ // cursor to stand in for; a stale pointer would sit there pointing at
162
+ // nothing until the mouse came back.
163
+ document.addEventListener('mouseleave', () => { layer.hidden = true; });
164
+ window.addEventListener('blur', () => { layer.hidden = true; });
165
+
166
+ // A click lands somewhere: the pointer acknowledges it — the dot swells,
167
+ // the ring snaps in, the dart lunges. Pure CSS off this class.
168
+ document.addEventListener('pointerdown', (event) => {
169
+ if (armed && event.button === 0) layer.classList.add('is-pressed');
170
+ });
171
+ document.addEventListener('pointerup', () => layer.classList.remove('is-pressed'));
172
+
173
+ // ---- arming ---------------------------------------------------------
174
+
175
+ // The real cursor stands down only while a stand-in is up: armed on the
176
+ // native kind, the mouse is its ordinary self and the overlay stays away.
177
+ const applyCursor = () => {
178
+ const standIn = armed && kind !== 'native';
179
+ document.documentElement.classList.toggle('deck-pointer-on', standIn);
180
+ layer.hidden = true; // shown again by the first move, in the right place
181
+ if (standIn) {
182
+ // Start the ring already caught up, not sprinting in from off-screen.
183
+ hx = x;
184
+ hy = y;
185
+ }
186
+ };
187
+
188
+ const setKind = (next) => {
189
+ kind = next;
190
+ layer.dataset.kind = kind;
191
+ for (const chip of picker.children) {
192
+ chip.setAttribute('aria-checked', String(chip.dataset.kind === kind));
193
+ }
194
+ applyCursor();
195
+ aim();
196
+ };
197
+
198
+ const setPointer = (on) => {
199
+ armed = on;
200
+ button.setAttribute('aria-pressed', String(on));
201
+ toolbar.classList.toggle('is-armed', on);
202
+ picker.hidden = !on;
203
+ applyCursor();
204
+ if (on) document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: 'pointer' }));
205
+ };
206
+
207
+ // Several tools, one pointer: arming any disarms the rest — same DOM-event
208
+ // handshake as the pen and the spotlight, so each plugin works alone.
209
+ document.addEventListener('deck:tool-armed', (event) => {
210
+ if (event.detail !== 'pointer' && armed) setPointer(false);
211
+ });
212
+
213
+ button.addEventListener('click', () => setPointer(!armed));
214
+ picker.addEventListener('click', (event) => {
215
+ const chip = event.target.closest('.deck-pointer-kind');
216
+ if (chip) setKind(chip.dataset.kind);
217
+ });
218
+
219
+ // `addKeyBinding` with a descriptor binds the key and lists it in reveal's
220
+ // own help overlay. `C` is unclaimed by reveal.
221
+ deck.addKeyBinding({ keyCode: KEY_CODE, key: 'C', description: 'Toggle the pointer' }, () =>
222
+ setPointer(!armed),
223
+ );
224
+ // While armed: Escape stands it down, the number row picks the kind — the
225
+ // same capture-phase borrowing the pen does with its colours.
226
+ document.addEventListener(
227
+ 'keydown',
228
+ (event) => {
229
+ if (!armed || event.metaKey || event.ctrlKey || event.altKey) return;
230
+ if (event.key === 'Escape') setPointer(false);
231
+ else {
232
+ const index = '123'.indexOf(event.key);
233
+ if (index === -1 || index >= KINDS.length) return;
234
+ setKind(KINDS[index].id);
235
+ }
236
+ event.stopPropagation();
237
+ event.preventDefault();
238
+ },
239
+ true,
240
+ );
241
+
242
+ // No `slidechanged` hook on purpose: the pointer is the cursor, and the
243
+ // cursor does not vanish because the slide moved on. The overview is fair
244
+ // ground for it too.
245
+ window.addEventListener('resize', aim);
246
+ },
247
+ });
248
+
249
+ export default RevealPointer;
@@ -449,10 +449,11 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
449
449
  }
450
450
 
451
451
  /* Faded back until it is wanted: on stage the slide is the point, and the
452
- toolbar sits in the one place the eye keeps returning to. */
452
+ toolbar keeps to the quiet strip along the bottom, between the arrows and
453
+ the signature. */
453
454
  #deck-tools {
454
455
  position: fixed;
455
- top: 14px;
456
+ bottom: 14px;
456
457
  left: 50%;
457
458
  transform: translateX(-50%);
458
459
  z-index: 40;
@@ -505,10 +506,11 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
505
506
  0 0 16px color-mix(in srgb, var(--pen, #ff8c1a) 55%, transparent);
506
507
  }
507
508
 
508
- /* The colours, hanging under the toolbar while the pen is armed. */
509
+ /* The colours, floating above the toolbar while a tool is armed — the
510
+ toolbar sits on the bottom edge, so up is where the room is. */
509
511
  #deck-swatches {
510
512
  position: absolute;
511
- top: calc(100% + 9px);
513
+ bottom: calc(100% + 9px);
512
514
  left: 50%;
513
515
  transform: translateX(-50%);
514
516
  display: flex;
@@ -821,6 +823,142 @@ body.outline-open #deck-outline { transform: none; }
821
823
  background: color-mix(in srgb, var(--text) 5%, transparent);
822
824
  }
823
825
 
826
+ /* --- the pointer (pointer.js) -------------------------------------------- */
827
+
828
+ /* The real cursor goes away everywhere while the pointer is armed — the
829
+ glowing stand-in below is the cursor now, toolbar included. */
830
+ html.deck-pointer-on,
831
+ html.deck-pointer-on * { cursor: none !important; }
832
+
833
+ /* Above everything, spotlight and ink included: it stands in for the cursor,
834
+ which always rides on top of what it points at. Never touchable. */
835
+ #deck-pointer {
836
+ position: fixed;
837
+ inset: 0;
838
+ z-index: 60;
839
+ pointer-events: none;
840
+ }
841
+ #deck-pointer[hidden] { display: none; }
842
+
843
+ /* Each piece sits at 0,0 and is carried by the `translate` property pointer.js
844
+ writes per frame; `transform` is left free for the press effect, so the two
845
+ compose instead of overwriting each other. */
846
+ /* The halo wears the pen's own orange (annotate.js's palette), not the
847
+ theme accent: on stage the pointer has to burn, not blend in. */
848
+ #deck-pointer { --pointer-hot: #ff8c1a; }
849
+
850
+ .deck-pointer-dot {
851
+ position: absolute;
852
+ left: -3.5px;
853
+ top: -3.5px;
854
+ width: 7px;
855
+ height: 7px;
856
+ border-radius: 999px;
857
+ background: #fff;
858
+ box-shadow:
859
+ 0 0 6px 1px rgb(255 255 255 / 0.9),
860
+ 0 0 14px 4px color-mix(in srgb, var(--pointer-hot) 70%, transparent);
861
+ transition: transform 0.12s ease;
862
+ }
863
+ /* The ring trails the dot on a short elastic (pointer.js lerps it), lit from
864
+ inside and haloed outside — the trailing is what makes it read as alive. */
865
+ .deck-pointer-halo {
866
+ position: absolute;
867
+ left: -19px;
868
+ top: -19px;
869
+ width: 38px;
870
+ height: 38px;
871
+ border-radius: 999px;
872
+ border: 3px solid color-mix(in srgb, var(--pointer-hot) 90%, #fff);
873
+ background: radial-gradient(circle, color-mix(in srgb, var(--pointer-hot) 30%, transparent) 0%, transparent 68%);
874
+ box-shadow:
875
+ 0 0 26px color-mix(in srgb, var(--pointer-hot) 75%, transparent),
876
+ inset 0 0 14px color-mix(in srgb, var(--pointer-hot) 55%, transparent);
877
+ transition: transform 0.16s ease, border-color 0.16s ease;
878
+ }
879
+ /* A click: the dot swells, the ring snaps in around it. */
880
+ #deck-pointer.is-pressed .deck-pointer-dot { transform: scale(1.7); }
881
+ #deck-pointer.is-pressed .deck-pointer-halo {
882
+ transform: scale(0.7);
883
+ border-color: #fff;
884
+ }
885
+
886
+ /* The compass dart. pointer.js sets `rotate` so it always aims at the centre
887
+ of the viewport; the pivot is its own middle, under the cursor's hotspot. */
888
+ .deck-pointer-compass {
889
+ position: absolute;
890
+ left: -21px;
891
+ top: -21px;
892
+ width: 42px;
893
+ height: 42px;
894
+ fill: var(--pointer-hot);
895
+ filter:
896
+ drop-shadow(0 0 3px rgb(255 255 255 / 0.5))
897
+ drop-shadow(0 0 12px color-mix(in srgb, var(--pointer-hot) 75%, transparent));
898
+ transition: transform 0.12s ease;
899
+ }
900
+ #deck-pointer.is-pressed .deck-pointer-compass { transform: scale(1.22); }
901
+
902
+ /* One kind on screen at a time; the native kind is the system cursor itself,
903
+ so it paints nothing at all. */
904
+ #deck-pointer[data-kind='halo'] .deck-pointer-compass { display: none; }
905
+ #deck-pointer[data-kind='compass'] .deck-pointer-dot,
906
+ #deck-pointer[data-kind='compass'] .deck-pointer-halo { display: none; }
907
+ #deck-pointer[data-kind='native'] > * { display: none; }
908
+
909
+ /* The armed pointer button wears the accent, not the pen's ink — it draws in
910
+ nothing, it glows. The id outweighs the generic armed-tool rule above. */
911
+ #tool-pointer[aria-pressed='true'] {
912
+ background: var(--accent);
913
+ color: #0d1626;
914
+ box-shadow:
915
+ 0 0 0 1px color-mix(in srgb, var(--text) 30%, transparent),
916
+ 0 0 16px color-mix(in srgb, var(--accent) 55%, transparent);
917
+ }
918
+
919
+ /* The kind picker, floating above the toolbar like the pen's swatches. */
920
+ #deck-pointer-kinds {
921
+ position: absolute;
922
+ bottom: calc(100% + 9px);
923
+ left: 50%;
924
+ transform: translateX(-50%);
925
+ display: flex;
926
+ gap: 6px;
927
+ padding: 5px;
928
+ background: color-mix(in srgb, var(--card) 82%, transparent);
929
+ border: 1px solid var(--line);
930
+ border-radius: 999px;
931
+ backdrop-filter: blur(6px);
932
+ }
933
+ #deck-pointer-kinds[hidden] { display: none; }
934
+
935
+ .deck-pointer-kind {
936
+ display: grid;
937
+ place-items: center;
938
+ width: 26px;
939
+ height: 26px;
940
+ padding: 0;
941
+ border: 0;
942
+ border-radius: 999px;
943
+ background: transparent;
944
+ color: var(--muted);
945
+ cursor: pointer;
946
+ transition: background 0.15s ease, color 0.15s ease;
947
+ }
948
+ .deck-pointer-kind svg {
949
+ display: block;
950
+ width: 15px;
951
+ height: 15px;
952
+ }
953
+ .deck-pointer-kind:hover {
954
+ background: color-mix(in srgb, var(--text) 10%, transparent);
955
+ color: var(--text);
956
+ }
957
+ .deck-pointer-kind[aria-checked='true'] {
958
+ background: var(--accent);
959
+ color: #0d1626;
960
+ }
961
+
824
962
  /* --- the spotlight (spotlight.js) ---------------------------------------- */
825
963
 
826
964
  /* One veil over the slides, under the ink: spotlight.js punches the lit
@@ -878,6 +1016,7 @@ body.outline-open #deck-outline { transform: none; }
878
1016
  #deck-ink,
879
1017
  #deck-spot,
880
1018
  #deck-spot-ring,
1019
+ #deck-pointer,
881
1020
  #deck-tools,
882
1021
  #deck-menu,
883
1022
  #deck-outline { display: none; }