fb-slides 0.1.2 → 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 |
@@ -151,6 +153,8 @@ export default {
151
153
  decks: 'decks', // folder of .md
152
154
  demos: 'demo', // folder behind a bare `<!-- demo: name -->`
153
155
  static: ['assets', 'demo'], // served and published; auto-detected when omitted
156
+ revealTheme: 'dracula', // one of reveal.js's own themes; omit for this one
157
+ webfonts: false, // let the reveal themes fetch their Google fonts
154
158
  theme: 'theme.css', // loaded after the base theme; auto when it exists
155
159
  favicon: 'assets/favicon.png',
156
160
 
@@ -193,6 +197,34 @@ rather than replaces. Almost everything hangs off the tokens in `:root`:
193
197
  }
194
198
  ```
195
199
 
200
+ ### reveal.js themes
201
+
202
+ The fifteen themes from [revealjs.com/themes](https://revealjs.com/themes/) are all in the
203
+ box — pick one by name and nothing else changes:
204
+
205
+ ```js
206
+ export default { revealTheme: 'dracula' };
207
+ ```
208
+
209
+ `beige` · `black` · `black-contrast` · `blood` · `dracula` · `league` · `moon` · `night` ·
210
+ `serif` · `simple` · `sky` · `solarized` · `white` · `white-contrast`
211
+
212
+ Only the chosen one is ever loaded — one `<link>`, ~7 KB — and the build copies that file
213
+ and nothing else. Two things happen beyond the link:
214
+
215
+ **It is served from the deck, not from a CDN.** Six of the themes open with
216
+ `@import url(https://fonts.googleapis.com/…)`. Those lines are stripped, so a talk still
217
+ renders with the wifi off; the theme falls back to the next font in its own stack. Pass
218
+ `webfonts: true` to let them through and get the typography of the previews exactly. The
219
+ font folders reveal ships itself — league-gothic, source-sans-pro — are copied into
220
+ `dist/` when the theme asks for them.
221
+
222
+ **The chrome follows the theme.** The navigator, the badge and the signature are not
223
+ `.reveal` elements, so a borrowed theme would leave a black panel down the side of a white
224
+ deck. Every reveal 5 theme declares its palette as `--r-*` custom properties, and the base
225
+ theme points its own tokens at them, so the whole page moves together. A `theme.css` in the
226
+ project still has the last word over both.
227
+
196
228
  ## Publishing
197
229
 
198
230
  ```bash
package/bin/fb-slides.mjs CHANGED
@@ -69,11 +69,12 @@ const run = async () => {
69
69
 
70
70
  const { loadConfig } = await import('../lib/config.mjs');
71
71
  const root = process.cwd();
72
- const config = await loadConfig(root, overrides());
72
+ const reload = () => loadConfig(root, overrides());
73
+ const config = await reload();
73
74
 
74
75
  if (command === 'dev') {
75
76
  const { dev } = await import('../lib/dev.mjs');
76
- return void (await dev(config, RUNTIME));
77
+ return void (await dev(config, RUNTIME, reload));
77
78
  }
78
79
 
79
80
  if (command === 'build') {
package/lib/build.mjs CHANGED
@@ -14,6 +14,7 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
14
14
  import { assertUsable } from './config.mjs';
15
15
  import { listDecks } from './decks.mjs';
16
16
  import { renderIndex } from './render.mjs';
17
+ import { REVEAL_THEME_URL, readRevealTheme } from './theme.mjs';
17
18
  import { VENDOR_FILES, VENDOR_MOUNTS, packageDir } from './vendor.mjs';
18
19
 
19
20
  const copyFile = async (from, to) => {
@@ -59,6 +60,18 @@ export const build = async (config, runtimeDir, { quiet = false } = {}) => {
59
60
  await cp(from, join(out, dir), { recursive: true, filter: (src) => !excluded(src) });
60
61
  }
61
62
 
63
+ // 3b. the reveal theme, rewritten, with the font folders it imports — and
64
+ // only those: source-sans-pro is 1.8 MB and most themes never ask for it.
65
+ if (config.revealTheme) {
66
+ const { css, fonts, dir } = await readRevealTheme(config.revealTheme, { webfonts: config.webfonts });
67
+ await writeFile(join(out, REVEAL_THEME_URL), css);
68
+ for (const font of fonts) {
69
+ await cp(join(dir, 'fonts', font), join(out, 'vendor', VENDOR_MOUNTS['reveal.js'], 'dist/theme/fonts', font), {
70
+ recursive: true,
71
+ });
72
+ }
73
+ }
74
+
62
75
  // 4. loose root files the config names — a theme override, a favicon.
63
76
  for (const file of [config.theme, config.favicon].filter(Boolean)) {
64
77
  const from = resolve(config.root, file);
@@ -73,7 +86,8 @@ export const build = async (config, runtimeDir, { quiet = false } = {}) => {
73
86
  await writeFile(join(out, 'decks.json'), `${JSON.stringify(decks, null, 2)}\n`);
74
87
 
75
88
  if (!quiet) {
76
- console.log(` ${config.outDir}/ ${decks.length} decks · ${config.static.join(', ') || 'no static dirs'} · ${Date.now() - started}ms`);
89
+ const theme = config.revealTheme ? ` · theme ${config.revealTheme}` : '';
90
+ console.log(` ${config.outDir}/ ← ${decks.length} decks · ${config.static.join(', ') || 'no static dirs'}${theme} · ${Date.now() - started}ms`);
77
91
  }
78
92
  return { decks, out };
79
93
  };
package/lib/config.mjs CHANGED
@@ -6,10 +6,12 @@
6
6
  // needs running. A project that follows the default shape needs no config at all.
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- import { existsSync } from 'node:fs';
9
+ import { existsSync, statSync } from 'node:fs';
10
10
  import { basename, join, resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
 
13
+ import { assertRevealTheme } from './theme.mjs';
14
+
13
15
  const CONFIG_FILES = ['slides.config.js', 'slides.config.mjs', 'slides.config.json'];
14
16
 
15
17
  // Directories a project gets served and published for free when it has them, so
@@ -22,7 +24,10 @@ export const ALWAYS_EXCLUDED = ['node_modules', '.git', '.angular', '.next', '.c
22
24
  export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find(existsSync) ?? null;
23
25
 
24
26
  const importConfig = async (file) => {
25
- const url = pathToFileURL(file).href;
27
+ // ESM caches a module by URL for the life of the process, so the dev server's
28
+ // second read of an edited config would be the first one again. The mtime in
29
+ // the query makes it a different URL exactly when the file has changed.
30
+ const url = `${pathToFileURL(file).href}?t=${statSync(file).mtimeMs}`;
26
31
  const module = file.endsWith('.json')
27
32
  ? await import(url, { with: { type: 'json' } })
28
33
  : await import(url);
@@ -53,6 +58,14 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
53
58
  decksDir,
54
59
  demosDir,
55
60
  static: statics,
61
+ // One of reveal's own fifteen — 'dracula', 'sky', 'white'. It lands after
62
+ // the base theme and takes the slides; the chrome follows it through the
63
+ // `--r-*` variables every reveal 5 theme declares. Null: this package's look.
64
+ revealTheme: user.revealTheme ?? null,
65
+ // Six of those themes pull their typefaces from fonts.googleapis.com. Off by
66
+ // default — the deck is served whole or not at all — at the cost of falling
67
+ // back to the theme's second-choice font stack.
68
+ webfonts: user.webfonts ?? false,
56
69
  // The project's own CSS, loaded *after* the base theme: an override, not a
57
70
  // replacement. `theme.css` at the root is picked up without being declared.
58
71
  theme: user.theme ?? (existsSync(join(root, 'theme.css')) ? 'theme.css' : null),
@@ -72,6 +85,10 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
72
85
  exclude: [...ALWAYS_EXCLUDED, overrides.outDir ?? user.outDir ?? 'dist', ...(user.exclude ?? [])],
73
86
  };
74
87
 
88
+ // Named early: a typo in the config should be a message, not a deck that
89
+ // silently wears the wrong clothes.
90
+ if (config.revealTheme) await assertRevealTheme(config.revealTheme);
91
+
75
92
  config.decksPath = resolve(root, config.decksDir);
76
93
  config.outPath = resolve(root, config.outDir);
77
94
  // What the browser asks for, as opposed to where it is on disk.
package/lib/dev.mjs CHANGED
@@ -13,6 +13,7 @@ import { join, resolve } from 'node:path';
13
13
  import { assertUsable } from './config.mjs';
14
14
  import { createDeckServer, listen } from './server.mjs';
15
15
  import { renderIndex } from './render.mjs';
16
+ import { REVEAL_THEME_URL, readRevealTheme } from './theme.mjs';
16
17
  import { VENDOR_MOUNTS, packageDir } from './vendor.mjs';
17
18
 
18
19
  const OPEN = { darwin: 'open', win32: 'start' };
@@ -52,8 +53,25 @@ const startSideServer = (spec, root, children) => {
52
53
  });
53
54
  };
54
55
 
55
- export const dev = async (config, runtimeDir) => {
56
+ // What the page is built from, read again on every request. A config with a
57
+ // syntax error in it — the state it is in halfway through an edit — leaves the
58
+ // last good one standing rather than serving a broken deck.
59
+ const reader = (config, reload) => {
60
+ let good = config;
61
+ return async () => {
62
+ if (!reload) return good;
63
+ try {
64
+ good = await reload();
65
+ } catch (error) {
66
+ console.warn(` ⚠ slides.config: ${error.message}`);
67
+ }
68
+ return good;
69
+ };
70
+ };
71
+
72
+ export const dev = async (config, runtimeDir, reload) => {
56
73
  assertUsable(config);
74
+ const current = reader(config, reload);
57
75
 
58
76
  const mounts = [
59
77
  // The project first: a file next to the decks shadows the one this package
@@ -69,8 +87,18 @@ export const dev = async (config, runtimeDir) => {
69
87
  const server = createDeckServer({
70
88
  mounts,
71
89
  decksPath: config.decksPath,
72
- // Rendered per request: editing slides.config.js and reloading is enough.
73
- index: () => renderIndex(config, runtimeDir),
90
+ // Rendered per request from a config read per request: editing the title,
91
+ // the theme or the signature and reloading is enough. What was fixed when
92
+ // the server came up — the mounts, the port, the side processes — still
93
+ // needs a restart.
94
+ index: async () => renderIndex(await current(), runtimeDir),
95
+ generated: {
96
+ [`/${REVEAL_THEME_URL}`]: async () => {
97
+ const now = await current();
98
+ if (!now.revealTheme) return '/* no revealTheme in slides.config.js */';
99
+ return (await readRevealTheme(now.revealTheme, { webfonts: now.webfonts })).css;
100
+ },
101
+ },
74
102
  });
75
103
 
76
104
  await listen(server, config.port);
package/lib/render.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
 
10
+ import { REVEAL_THEME_URL } from './theme.mjs';
11
+
10
12
  const escapeHtml = (value) =>
11
13
  String(value).replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
12
14
 
@@ -28,6 +30,9 @@ const signatureHtml = (signature) => {
28
30
  const headHtml = (config) => {
29
31
  const tags = [];
30
32
  if (config.favicon) tags.push(`<link rel="icon" href="${escapeHtml(config.favicon)}" />`);
33
+ // After the base theme, before the project's: reveal's theme dresses the
34
+ // slides, and whatever the project says still has the last word.
35
+ if (config.revealTheme) tags.push(`<link rel="stylesheet" href="${REVEAL_THEME_URL}" />`);
31
36
  // After the base theme on purpose: the project's CSS overrides, it does not replace.
32
37
  if (config.theme) tags.push(`<link rel="stylesheet" href="${escapeHtml(config.theme)}" />`);
33
38
  return tags.join('\n ');
@@ -43,7 +48,12 @@ export const renderIndex = async (config, runtimeDir) => {
43
48
  fragmentLists: config.fragmentLists,
44
49
  };
45
50
 
51
+ // The chrome reads this to take its colours from the reveal theme instead of
52
+ // the base one — see the bridge at the top of theme.base.css.
53
+ const htmlAttrs = config.revealTheme ? ` data-reveal-theme="${escapeHtml(config.revealTheme)}"` : '';
54
+
46
55
  return template
56
+ .replaceAll('{{htmlAttrs}}', htmlAttrs)
47
57
  .replaceAll('{{lang}}', escapeHtml(config.lang))
48
58
  .replaceAll('{{title}}', escapeHtml(config.title))
49
59
  .replaceAll('{{head}}', headHtml(config))
package/lib/server.mjs CHANGED
@@ -99,7 +99,9 @@ const sendFile = (req, res, path, size) => {
99
99
  };
100
100
 
101
101
  // mounts: [{ prefix: '/', dir }] — checked in order, first hit wins.
102
- export const createDeckServer = ({ mounts, index, decksPath }) => {
102
+ // generated: { '/path.css': async () => body } files this package rewrites
103
+ // rather than serves, checked before any mount so node_modules cannot shadow one.
104
+ export const createDeckServer = ({ mounts, index, decksPath, generated = {} }) => {
103
105
  const handler = async (req, res) => {
104
106
  if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'method not allowed');
105
107
 
@@ -120,6 +122,10 @@ export const createDeckServer = ({ mounts, index, decksPath }) => {
120
122
  return send(res, 200, body, { 'Content-Type': MIME['.json'] });
121
123
  }
122
124
 
125
+ if (generated[pathname]) {
126
+ return send(res, 200, await generated[pathname](), { 'Content-Type': mime(pathname) });
127
+ }
128
+
123
129
  if (DENIED.test(pathname)) return send(res, 403, 'forbidden', { 'Content-Type': MIME['.txt'] });
124
130
 
125
131
  for (const { prefix, dir } of mounts) {
package/lib/theme.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // ---------------------------------------------------------------------------
2
+ // reveal.js ships fifteen themes; a deck wears one of them, or none.
3
+ //
4
+ // They are not linked straight out of node_modules. Six of the fifteen open with
5
+ // `@import url(https://fonts.googleapis.com/…)`, which is a third-party request
6
+ // on every load — and a talk that has to survive the conference wifi cannot rest
7
+ // on one. The file is rewritten instead: the remote imports go, the font folders
8
+ // reveal already ships locally stay, and what the browser gets is served from
9
+ // this project like everything else.
10
+ //
11
+ // The rewritten file is generated per request in dev and written once by build,
12
+ // at the same URL either way — index.html never learns which of the two it is.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { readdir, readFile } from 'node:fs/promises';
16
+ import { basename, join } from 'node:path';
17
+
18
+ import { VENDOR_MOUNTS, packageDir } from './vendor.mjs';
19
+
20
+ export const REVEAL_THEME_URL = 'reveal-theme.css';
21
+
22
+ const themeDir = () => join(packageDir('reveal.js'), 'dist', 'theme');
23
+
24
+ // The theme is served from the site root, so reveal's own `./fonts/…` would
25
+ // resolve one folder too high: point it at the vendor mount instead.
26
+ const FONT_BASE = `vendor/${VENDOR_MOUNTS['reveal.js']}/dist/theme/fonts/`;
27
+
28
+ const REMOTE_IMPORT = /@import\s+url\(\s*['"]?https?:[^)]*\)\s*;?[ \t]*\n?/gi;
29
+ const LOCAL_FONT_URL = /url\(\s*['"]?\.\/fonts\//gi;
30
+
31
+ export const listRevealThemes = async () =>
32
+ (await readdir(themeDir()))
33
+ .filter((file) => file.endsWith('.css'))
34
+ .map((file) => basename(file, '.css'))
35
+ .sort();
36
+
37
+ export const assertRevealTheme = async (name) => {
38
+ const themes = await listRevealThemes();
39
+ if (themes.includes(name)) return name;
40
+ throw new Error(
41
+ `unknown revealTheme: ${name}\n` +
42
+ ` reveal.js ships: ${themes.join(', ')}\n` +
43
+ ` drop the key for this package's own theme`,
44
+ );
45
+ };
46
+
47
+ // The font folders the chosen theme actually imports. build copies these and
48
+ // nothing else: source-sans-pro alone is 1.8 MB, and most themes never ask for it.
49
+ const fontDirs = (css) => [...new Set([...css.matchAll(/\.\/fonts\/([^/]+)\//g)].map((m) => m[1]))];
50
+
51
+ // `webfonts: true` keeps the Google Fonts imports — the exact typography of
52
+ // revealjs.com/themes, at the cost of the offline guarantee.
53
+ export const readRevealTheme = async (name, { webfonts = false } = {}) => {
54
+ const source = await readFile(join(themeDir(), `${name}.css`), 'utf8');
55
+ const css = (webfonts ? source : source.replace(REMOTE_IMPORT, '')).replace(LOCAL_FONT_URL, `url(${FONT_BASE}`);
56
+ return { css, fonts: fontDirs(source), dir: themeDir() };
57
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.1.2",
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.
@@ -25,9 +29,11 @@ const COLOURS = [
25
29
  { label: 'Black', glow: '#000000', core: '#000000' },
26
30
  ];
27
31
 
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.
32
+ // A released stroke holds on screen for HOLD_MS, then rubs itself out from its
33
+ // oldest point towards its newest. FADE_MS is how long the erase takes end to
34
+ // end, FADE_TAIL how much of the stroke the erase front softens as it passes —
35
+ // at 0 it would be a hard wipe.
36
+ const HOLD_MS = 2000;
31
37
  const FADE_MS = 1600;
32
38
  const FADE_TAIL = 0.35;
33
39
 
@@ -44,6 +50,14 @@ const TENSION = 1 / 6;
44
50
 
45
51
  const WIDTH = 3.4; // px, the bright core of the line
46
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
+
47
61
  // Two layers, each the same path stroked at a few widths: a soft halo, then the
48
62
  // core on top of it. Within a layer the passes stack additively, hence the low
49
63
  // alphas. Cheaper than a shadowBlur per segment.
@@ -62,8 +76,10 @@ const QUANTA = 24;
62
76
  // `P` is reveal's own previous-slide key, and a plugin binding wins over it —
63
77
  // which is the point: on stage the pen is reached for far more often than a key
64
78
  // ← 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;
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;
67
83
  const HELP_TAKEN_FROM = { key: 'P', from: /previous/i };
68
84
 
69
85
  const PEN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
@@ -71,6 +87,11 @@ const PEN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" str
71
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" />
72
88
  </svg>`;
73
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
+
74
95
  // Black text on a light chip, light text on a dark one — the armed pen button
75
96
  // wears the ink's own colour, and two of the six are the extremes.
76
97
  const readable = (hex) => {
@@ -124,7 +145,9 @@ const RevealAnnotate = () => ({
124
145
  toolbar.innerHTML = `
125
146
  <button id="tool-pen" class="deck-tool" type="button" aria-pressed="false"
126
147
  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>
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>
128
151
  ${COLOURS.map(
129
152
  (colour, i) => `<button class="deck-swatch" type="button" role="radio"
130
153
  aria-checked="${i === 0}" data-colour="${i}" style="--ink:${colour.glow}"
@@ -135,6 +158,7 @@ const RevealAnnotate = () => ({
135
158
 
136
159
  document.body.append(canvas, toolbar);
137
160
  const penButton = toolbar.querySelector('#tool-pen');
161
+ const arrowButton = toolbar.querySelector('#tool-arrow');
138
162
  const swatches = toolbar.querySelector('#deck-swatches');
139
163
  const ctx = canvas.getContext('2d');
140
164
 
@@ -206,10 +230,14 @@ const RevealAnnotate = () => ({
206
230
  // ---- strokes --------------------------------------------------------
207
231
  // A stroke is its points, the colour it was drawn in, and the moment the
208
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.
209
235
 
210
236
  const strokes = [];
211
- 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
212
239
  let frame = 0;
240
+ let holdTimer = 0;
213
241
 
214
242
  const addPoint = (event) => {
215
243
  const points = drawing.points;
@@ -282,11 +310,65 @@ const RevealAnnotate = () => ({
282
310
  return runs;
283
311
  };
284
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
+
285
360
  // Returns false once the stroke has faded out and can be dropped.
286
361
  const paint = (stroke, now) => {
287
- const progress = stroke.releasedAt === null ? null : (now - stroke.releasedAt) / FADE_MS;
362
+ // Clamped at 0 through the hold: a stroke waiting its turn paints exactly
363
+ // like one still under the pen, only released.
364
+ const progress =
365
+ stroke.releasedAt === null
366
+ ? null
367
+ : Math.max(0, (now - stroke.releasedAt - HOLD_MS) / FADE_MS);
288
368
  if (progress !== null && progress >= 1) return false;
289
369
 
370
+ if (stroke.kind === 'arrow') return paintArrow(stroke, progress);
371
+
290
372
  const box = boundsOf(stroke.points);
291
373
  if (box.w <= 0 || box.h <= 0) return true;
292
374
 
@@ -334,25 +416,36 @@ const RevealAnnotate = () => ({
334
416
  if (!paint(strokes[i], now)) strokes.splice(i, 1);
335
417
  }
336
418
  // 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();
419
+ // point is added and not before, and a released one sits untouched
420
+ // through its hold for those a single timeout wakes the loop when the
421
+ // first fade is due, instead of repainting a full-screen canvas forever.
422
+ const waits = strokes
423
+ .filter((stroke) => stroke.releasedAt !== null)
424
+ .map((stroke) => stroke.releasedAt + HOLD_MS - now);
425
+ if (waits.some((wait) => wait <= 0)) schedule();
426
+ else if (waits.length) holdTimer = setTimeout(schedule, Math.min(...waits));
340
427
  };
341
428
 
342
429
  const schedule = () => {
430
+ clearTimeout(holdTimer);
431
+ holdTimer = 0;
343
432
  frame ||= requestAnimationFrame(render);
344
433
  };
345
434
 
346
435
  const clear = () => {
347
436
  drawing = null;
437
+ placing = null;
348
438
  strokes.length = 0;
439
+ clearTimeout(holdTimer);
440
+ holdTimer = 0;
349
441
  ctx.clearRect(0, 0, width, height);
350
442
  };
351
443
 
352
- // ---- the pen --------------------------------------------------------
444
+ // ---- the tools ------------------------------------------------------
353
445
 
354
- let penOn = false;
446
+ let tool = null; // null, 'pen' or 'arrow'
355
447
  let ink = COLOURS[0];
448
+ const toolButtons = { pen: penButton, arrow: arrowButton };
356
449
 
357
450
  const setInk = (colour) => {
358
451
  ink = colour;
@@ -364,29 +457,60 @@ const RevealAnnotate = () => ({
364
457
  }
365
458
  };
366
459
 
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;
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;
374
481
  // Only an armed canvas takes the pointer; the rest of the time clicks
375
482
  // 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' }));
483
+ canvas.classList.toggle('is-live', tool !== null);
484
+ if (tool) document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: tool }));
378
485
  else clear();
379
486
  };
487
+ const toggleTool = (name) => setTool(tool === name ? null : name);
380
488
 
381
- // Two tools, one pointer: arming either disarms the other. The handshake
489
+ // Several tools, one pointer: arming any disarms the rest. The handshake
382
490
  // is a DOM event rather than an import either way round, so each plugin
383
- // works alone.
491
+ // works alone. Pen and arrow are both this plugin's, so only a foreign
492
+ // detail stands it down.
384
493
  document.addEventListener('deck:tool-armed', (event) => {
385
- if (event.detail !== 'pen' && penOn) setPen(false);
494
+ if (event.detail !== 'pen' && event.detail !== 'arrow' && tool) setTool(null);
386
495
  });
387
496
 
388
497
  canvas.addEventListener('pointerdown', (event) => {
389
- 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
+ }
390
514
  canvas.setPointerCapture(event.pointerId);
391
515
  // The colour is caught at the down-stroke, so a line already fading keeps
392
516
  // the one it was drawn in when you pick another.
@@ -397,6 +521,12 @@ const RevealAnnotate = () => ({
397
521
  });
398
522
 
399
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
+ }
400
530
  if (!drawing || event.pointerId !== drawing.id) return;
401
531
  // A trackpad reports faster than the display refreshes, and the samples
402
532
  // in between are what keeps a quick curve smooth instead of faceted.
@@ -408,6 +538,19 @@ const RevealAnnotate = () => ({
408
538
  });
409
539
 
410
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
+ }
411
554
  if (!drawing || event.pointerId !== drawing.id) return;
412
555
  drawing.releasedAt = performance.now();
413
556
  drawing = null;
@@ -416,7 +559,8 @@ const RevealAnnotate = () => ({
416
559
  canvas.addEventListener('pointerup', release);
417
560
  canvas.addEventListener('pointercancel', release);
418
561
 
419
- penButton.addEventListener('click', () => setPen(!penOn));
562
+ penButton.addEventListener('click', () => toggleTool('pen'));
563
+ arrowButton.addEventListener('click', () => toggleTool('arrow'));
420
564
  swatches.addEventListener('click', (event) => {
421
565
  const button = event.target.closest('.deck-swatch');
422
566
  if (button) setInk(COLOURS[button.dataset.colour]);
@@ -424,17 +568,27 @@ const RevealAnnotate = () => ({
424
568
 
425
569
  // `addKeyBinding` with a descriptor binds the key and lists it in reveal's
426
570
  // own help overlay, which `registerKeyboardShortcut` alone would not do.
427
- deck.addKeyBinding({ keyCode: KEY_CODE, key: 'P', description: 'Toggle the pen' }, () =>
428
- 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'),
429
576
  );
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.
577
+ // Keys the tools borrow only while one is armed, taken in capture phase so
578
+ // reveal's own bindings never see them: Escape (otherwise the overview
579
+ // toggle) disarms it, and the number row picks the ink 1 to 6, in the
580
+ // order the swatches sit in the toolbar. The rest of the time both fall
581
+ // through untouched.
433
582
  document.addEventListener(
434
583
  'keydown',
435
584
  (event) => {
436
- if (event.key !== 'Escape' || !penOn) return;
437
- setPen(false);
585
+ if (!tool || event.metaKey || event.ctrlKey || event.altKey) return;
586
+ if (event.key === 'Escape') setTool(null);
587
+ else {
588
+ const index = '123456'.indexOf(event.key);
589
+ if (index === -1 || index >= COLOURS.length) return;
590
+ setInk(COLOURS[index]);
591
+ }
438
592
  event.stopPropagation();
439
593
  event.preventDefault();
440
594
  },
@@ -447,7 +601,7 @@ const RevealAnnotate = () => ({
447
601
  // A new slide is a clean sheet — including a line still under the pointer.
448
602
  deck.on('slidechanged', clear);
449
603
  // The overview is a different surface; drawing over it would annotate nothing.
450
- deck.on('overviewshown', () => setPen(false));
604
+ deck.on('overviewshown', () => setTool(null));
451
605
 
452
606
  window.addEventListener('resize', resize);
453
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
  // ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="{{lang}}">
2
+ <html lang="{{lang}}"{{htmlAttrs}}>
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -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;
@@ -29,6 +29,39 @@
29
29
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
30
30
  }
31
31
 
32
+ /* --- wearing one of reveal's own themes ----------------------------------- */
33
+
34
+ /* `revealTheme:` in slides.config.js loads reveal's theme after this file, where
35
+ it takes the slides. The chrome — the navigator, the badge, the signature —
36
+ is not `.reveal`-scoped, so it would sail past untouched and leave a black
37
+ panel down the side of a white deck.
38
+
39
+ Every reveal 5 theme declares its palette as `--r-*` custom properties, so
40
+ the fix is one indirection rather than fifteen stylesheets: point the tokens
41
+ above at reveal's, and the whole of the chrome follows whatever the deck
42
+ wears. The four reveal has no equivalent for are mixed from the two that
43
+ carry the theme, which keeps the panel's depth on a light theme as well as
44
+ a dark one. */
45
+ html[data-reveal-theme] {
46
+ --bg: var(--r-background-color);
47
+ --text: var(--r-main-color);
48
+ --accent: var(--r-link-color);
49
+ --accent-2: var(--r-link-color-hover, var(--r-link-color));
50
+ --sans: var(--r-main-font);
51
+ --mono: var(--r-code-font);
52
+ --bg-2: color-mix(in srgb, var(--r-background-color) 92%, var(--r-main-color));
53
+ --card: color-mix(in srgb, var(--r-background-color) 86%, var(--r-main-color));
54
+ --line: color-mix(in srgb, var(--r-main-color) 20%, transparent);
55
+ --muted: color-mix(in srgb, var(--r-main-color) 62%, var(--r-background-color));
56
+ }
57
+
58
+ /* The base look paints two dark halos on <html>, below the deck. They are the
59
+ wrong atmosphere for a borrowed theme, and on a light one they show through
60
+ wherever the viewport does not reach — so the theme's own flat colour takes
61
+ over. <body> is left alone on purpose: reveal paints the deck's background
62
+ there, and that is the theme's job now. */
63
+ html[data-reveal-theme] { background: var(--bg); }
64
+
32
65
  /* reveal.js 5 paints the page background on `.reveal-viewport` (the <html>
33
66
  element), so setting it on <body> would sit behind it and never show. */
34
67
  html,
@@ -111,6 +144,7 @@ body,
111
144
  /* Trailing margin would push centred content off-centre. */
112
145
  .reveal .slides section > :last-child { margin-bottom: 0; }
113
146
  .reveal strong { color: #fff; font-weight: 650; }
147
+ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--text)); }
114
148
  .reveal em { color: var(--accent-2); font-style: italic; }
115
149
  .reveal a { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
116
150
 
@@ -415,10 +449,11 @@ body,
415
449
  }
416
450
 
417
451
  /* Faded back until it is wanted: on stage the slide is the point, and the
418
- 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. */
419
454
  #deck-tools {
420
455
  position: fixed;
421
- top: 14px;
456
+ bottom: 14px;
422
457
  left: 50%;
423
458
  transform: translateX(-50%);
424
459
  z-index: 40;
@@ -471,10 +506,11 @@ body,
471
506
  0 0 16px color-mix(in srgb, var(--pen, #ff8c1a) 55%, transparent);
472
507
  }
473
508
 
474
- /* 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. */
475
511
  #deck-swatches {
476
512
  position: absolute;
477
- top: calc(100% + 9px);
513
+ bottom: calc(100% + 9px);
478
514
  left: 50%;
479
515
  transform: translateX(-50%);
480
516
  display: flex;
@@ -787,6 +823,142 @@ body.outline-open #deck-outline { transform: none; }
787
823
  background: color-mix(in srgb, var(--text) 5%, transparent);
788
824
  }
789
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
+
790
962
  /* --- the spotlight (spotlight.js) ---------------------------------------- */
791
963
 
792
964
  /* One veil over the slides, under the ink: spotlight.js punches the lit
@@ -844,6 +1016,7 @@ body.outline-open #deck-outline { transform: none; }
844
1016
  #deck-ink,
845
1017
  #deck-spot,
846
1018
  #deck-spot-ring,
1019
+ #deck-pointer,
847
1020
  #deck-tools,
848
1021
  #deck-menu,
849
1022
  #deck-outline { display: none; }
@@ -9,6 +9,18 @@ export default {
9
9
  // Folder behind a bare `<!-- demo: counter -->` marker in the Markdown.
10
10
  demos: 'demo',
11
11
 
12
+ // One of reveal.js's own themes, served from this project rather than from a
13
+ // CDN: beige, black, black-contrast, blood, dracula, league, moon, night,
14
+ // serif, simple, sky, solarized, white, white-contrast. Uncomment to wear one
15
+ // — the deck's chrome follows it. Leave it out for this package's own look.
16
+ // revealTheme: 'dracula',
17
+
18
+ // Six of those themes ask fonts.googleapis.com for their typefaces. Those
19
+ // requests are stripped out, so a deck never depends on the room's wifi; the
20
+ // theme falls back to the next font in its own stack. Set this to true to let
21
+ // them through and get the typography of revealjs.com/themes exactly.
22
+ // webfonts: true,
23
+
12
24
  // Port `fb-slides dev` serves the deck on (`preview` uses the next one up).
13
25
  // `--port n` on the command line wins over this.
14
26
  port: 4000,