fb-slides 0.3.0 → 0.4.1

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.1",
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 = 56;
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
 
@@ -229,8 +254,9 @@ const RevealAnnotate = () => ({
229
254
 
230
255
  // The slab of canvas a stroke can reach, clamped to the viewport. Padded by
231
256
  // half the widest pass, plus a little for the curve's overshoot between
232
- // points.
233
- const boundsOf = (points) => {
257
+ // points; `extra` widens it further for shapes whose paint reaches past
258
+ // the points themselves.
259
+ const boundsOf = (points, extra = 0) => {
234
260
  let minX = Infinity;
235
261
  let minY = Infinity;
236
262
  let maxX = -Infinity;
@@ -241,7 +267,7 @@ const RevealAnnotate = () => ({
241
267
  if (x > maxX) maxX = x;
242
268
  if (y > maxY) maxY = y;
243
269
  }
244
- const pad = MAX_WIDTH / 2 + 3;
270
+ const pad = MAX_WIDTH / 2 + 3 + extra;
245
271
  const x = Math.max(0, Math.floor(minX - pad));
246
272
  const y = Math.max(0, Math.floor(minY - pad));
247
273
  return {
@@ -285,6 +311,58 @@ const RevealAnnotate = () => ({
285
311
  return runs;
286
312
  };
287
313
 
314
+ // An arrow has no oldest end worth erasing first: it fades out whole, the
315
+ // shaft and its head together. The shaft ends where the head begins, so
316
+ // the glow's rounded cap never pokes past the tip.
317
+ const paintArrow = (stroke, progress) => {
318
+ const { from, to } = stroke;
319
+ const length = Math.hypot(to.x - from.x, to.y - from.y);
320
+ if (length < 1) return true;
321
+
322
+ const fade = progress === null ? 1 : 1 - progress;
323
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
324
+ const head = Math.min(HEAD_LEN, length * 0.6);
325
+ const wings = [HEAD_SPREAD, -HEAD_SPREAD].map((spread) => ({
326
+ x: to.x - head * Math.cos(angle + spread),
327
+ y: to.y - head * Math.sin(angle + spread),
328
+ }));
329
+ const base = {
330
+ x: to.x - head * 0.75 * Math.cos(angle),
331
+ y: to.y - head * 0.75 * Math.sin(angle),
332
+ };
333
+
334
+ // The wings go into the bounds along with the line's own ends: an
335
+ // axis-aligned arrow has a razor-thin from-to box, and the head reaches
336
+ // further out of it sideways than the stroke padding covers. The extra
337
+ // head-width of pad keeps the glow well clear of the clip on every side.
338
+ const box = boundsOf([from, to, ...wings], head);
339
+ if (box.w <= 0 || box.h <= 0) return true;
340
+
341
+ for (const { ink, passes } of LAYERS) {
342
+ onLayer(box, stroke.ink[ink], (c) => {
343
+ c.lineCap = 'round';
344
+ for (const pass of passes) {
345
+ c.lineWidth = WIDTH * pass.width;
346
+ c.strokeStyle = c.fillStyle = `rgba(255, 255, 255, ${fade * pass.alpha})`;
347
+ c.beginPath();
348
+ c.moveTo(from.x, from.y);
349
+ c.lineTo(base.x, base.y);
350
+ c.stroke();
351
+ // The head is filled for its body and stroked for its glow: the
352
+ // stroke fattens it by the same width the shaft wears.
353
+ c.beginPath();
354
+ c.moveTo(to.x, to.y);
355
+ c.lineTo(wings[0].x, wings[0].y);
356
+ c.lineTo(wings[1].x, wings[1].y);
357
+ c.closePath();
358
+ c.fill();
359
+ c.stroke();
360
+ }
361
+ });
362
+ }
363
+ return true;
364
+ };
365
+
288
366
  // Returns false once the stroke has faded out and can be dropped.
289
367
  const paint = (stroke, now) => {
290
368
  // Clamped at 0 through the hold: a stroke waiting its turn paints exactly
@@ -295,6 +373,8 @@ const RevealAnnotate = () => ({
295
373
  : Math.max(0, (now - stroke.releasedAt - HOLD_MS) / FADE_MS);
296
374
  if (progress !== null && progress >= 1) return false;
297
375
 
376
+ if (stroke.kind === 'arrow') return paintArrow(stroke, progress);
377
+
298
378
  const box = boundsOf(stroke.points);
299
379
  if (box.w <= 0 || box.h <= 0) return true;
300
380
 
@@ -360,16 +440,18 @@ const RevealAnnotate = () => ({
360
440
 
361
441
  const clear = () => {
362
442
  drawing = null;
443
+ placing = null;
363
444
  strokes.length = 0;
364
445
  clearTimeout(holdTimer);
365
446
  holdTimer = 0;
366
447
  ctx.clearRect(0, 0, width, height);
367
448
  };
368
449
 
369
- // ---- the pen --------------------------------------------------------
450
+ // ---- the tools ------------------------------------------------------
370
451
 
371
- let penOn = false;
452
+ let tool = null; // null, 'pen' or 'arrow'
372
453
  let ink = COLOURS[0];
454
+ const toolButtons = { pen: penButton, arrow: arrowButton };
373
455
 
374
456
  const setInk = (colour) => {
375
457
  ink = colour;
@@ -381,29 +463,60 @@ const RevealAnnotate = () => ({
381
463
  }
382
464
  };
383
465
 
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;
466
+ const setTool = (next) => {
467
+ if (next === tool) return;
468
+ // Whatever was mid-gesture under the old tool does not survive the
469
+ // switch: a half-drawn line is released, a half-aimed arrow discarded.
470
+ if (drawing) {
471
+ drawing.releasedAt = performance.now();
472
+ drawing = null;
473
+ }
474
+ if (placing) {
475
+ strokes.splice(strokes.indexOf(placing), 1);
476
+ placing = null;
477
+ schedule();
478
+ }
479
+ tool = next;
480
+ for (const [name, button] of Object.entries(toolButtons)) {
481
+ button.setAttribute('aria-pressed', String(name === tool));
482
+ }
483
+ toolbar.classList.toggle('is-armed', tool !== null);
484
+ // The colours are only worth showing while there is a tool to apply them
485
+ // to, which makes arming one the gesture that opens them.
486
+ swatches.hidden = tool === null;
391
487
  // Only an armed canvas takes the pointer; the rest of the time clicks
392
488
  // 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' }));
489
+ canvas.classList.toggle('is-live', tool !== null);
490
+ if (tool) document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: tool }));
395
491
  else clear();
396
492
  };
493
+ const toggleTool = (name) => setTool(tool === name ? null : name);
397
494
 
398
- // Two tools, one pointer: arming either disarms the other. The handshake
495
+ // Several tools, one pointer: arming any disarms the rest. The handshake
399
496
  // is a DOM event rather than an import either way round, so each plugin
400
- // works alone.
497
+ // works alone. Pen and arrow are both this plugin's, so only a foreign
498
+ // detail stands it down.
401
499
  document.addEventListener('deck:tool-armed', (event) => {
402
- if (event.detail !== 'pen' && penOn) setPen(false);
500
+ if (event.detail !== 'pen' && event.detail !== 'arrow' && tool) setTool(null);
403
501
  });
404
502
 
405
503
  canvas.addEventListener('pointerdown', (event) => {
406
- if (!penOn || event.button !== 0) return;
504
+ if (!tool || event.button !== 0) return;
505
+ if (tool === 'arrow') {
506
+ // Second click: the tip lands here. First click: the tail does, and
507
+ // the tip follows the pointer until the next one.
508
+ if (placing) {
509
+ placing.to = { x: event.clientX, y: event.clientY };
510
+ placing.releasedAt = performance.now();
511
+ placing = null;
512
+ } else {
513
+ const at = { x: event.clientX, y: event.clientY };
514
+ placing = { kind: 'arrow', from: at, to: { ...at }, releasedAt: null, ink };
515
+ strokes.push(placing);
516
+ }
517
+ schedule();
518
+ return;
519
+ }
407
520
  canvas.setPointerCapture(event.pointerId);
408
521
  // The colour is caught at the down-stroke, so a line already fading keeps
409
522
  // the one it was drawn in when you pick another.
@@ -414,6 +527,12 @@ const RevealAnnotate = () => ({
414
527
  });
415
528
 
416
529
  canvas.addEventListener('pointermove', (event) => {
530
+ // An arrow being aimed follows the bare pointer — no button held.
531
+ if (placing) {
532
+ placing.to = { x: event.clientX, y: event.clientY };
533
+ schedule();
534
+ return;
535
+ }
417
536
  if (!drawing || event.pointerId !== drawing.id) return;
418
537
  // A trackpad reports faster than the display refreshes, and the samples
419
538
  // in between are what keeps a quick curve smooth instead of faceted.
@@ -425,6 +544,19 @@ const RevealAnnotate = () => ({
425
544
  });
426
545
 
427
546
  const release = (event) => {
547
+ // A drag long enough to be deliberate is the whole gesture: down at the
548
+ // tail, up at the tip. Anything shorter was a click, and the arrow stays
549
+ // under the pointer waiting for the second one.
550
+ if (placing) {
551
+ const span = Math.hypot(event.clientX - placing.from.x, event.clientY - placing.from.y);
552
+ if (span >= MIN_ARROW) {
553
+ placing.to = { x: event.clientX, y: event.clientY };
554
+ placing.releasedAt = performance.now();
555
+ placing = null;
556
+ schedule();
557
+ }
558
+ return;
559
+ }
428
560
  if (!drawing || event.pointerId !== drawing.id) return;
429
561
  drawing.releasedAt = performance.now();
430
562
  drawing = null;
@@ -433,7 +565,8 @@ const RevealAnnotate = () => ({
433
565
  canvas.addEventListener('pointerup', release);
434
566
  canvas.addEventListener('pointercancel', release);
435
567
 
436
- penButton.addEventListener('click', () => setPen(!penOn));
568
+ penButton.addEventListener('click', () => toggleTool('pen'));
569
+ arrowButton.addEventListener('click', () => toggleTool('arrow'));
437
570
  swatches.addEventListener('click', (event) => {
438
571
  const button = event.target.closest('.deck-swatch');
439
572
  if (button) setInk(COLOURS[button.dataset.colour]);
@@ -441,19 +574,22 @@ const RevealAnnotate = () => ({
441
574
 
442
575
  // `addKeyBinding` with a descriptor binds the key and lists it in reveal's
443
576
  // own help overlay, which `registerKeyboardShortcut` alone would not do.
444
- deck.addKeyBinding({ keyCode: KEY_CODE, key: 'P', description: 'Toggle the pen' }, () =>
445
- setPen(!penOn),
577
+ deck.addKeyBinding({ keyCode: PEN_KEY_CODE, key: 'P', description: 'Toggle the pen' }, () =>
578
+ toggleTool('pen'),
579
+ );
580
+ deck.addKeyBinding({ keyCode: ARROW_KEY_CODE, key: 'A', description: 'Toggle the arrow' }, () =>
581
+ toggleTool('arrow'),
446
582
  );
447
- // Keys the pen borrows only while it is armed, taken in capture phase so
583
+ // Keys the tools borrow only while one is armed, taken in capture phase so
448
584
  // 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
585
+ // toggle) disarms it, and the number row picks the ink — 1 to 6, in the
450
586
  // order the swatches sit in the toolbar. The rest of the time both fall
451
587
  // through untouched.
452
588
  document.addEventListener(
453
589
  'keydown',
454
590
  (event) => {
455
- if (!penOn || event.metaKey || event.ctrlKey || event.altKey) return;
456
- if (event.key === 'Escape') setPen(false);
591
+ if (!tool || event.metaKey || event.ctrlKey || event.altKey) return;
592
+ if (event.key === 'Escape') setTool(null);
457
593
  else {
458
594
  const index = '123456'.indexOf(event.key);
459
595
  if (index === -1 || index >= COLOURS.length) return;
@@ -471,7 +607,7 @@ const RevealAnnotate = () => ({
471
607
  // A new slide is a clean sheet — including a line still under the pointer.
472
608
  deck.on('slidechanged', clear);
473
609
  // The overview is a different surface; drawing over it would annotate nothing.
474
- deck.on('overviewshown', () => setPen(false));
610
+ deck.on('overviewshown', () => setTool(null));
475
611
 
476
612
  window.addEventListener('resize', resize);
477
613
  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; }