reveal.js-phone-remote 0.1.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/LICENSE +21 -0
- package/README.md +184 -0
- package/package.json +56 -0
- package/src/deck/plugin.js +189 -0
- package/src/deck/spotlight.js +100 -0
- package/src/phone/i18n.js +82 -0
- package/src/phone/index.html +92 -0
- package/src/phone/remote.css +392 -0
- package/src/phone/remote.js +352 -0
- package/src/server/announce.js +84 -0
- package/src/server/index.js +2 -0
- package/src/server/middleware.js +195 -0
- package/src/server/vite.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jan Huege
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# reveal.js-phone-remote
|
|
2
|
+
|
|
3
|
+
Control a [reveal.js](https://revealjs.com) deck from your phone – no app, no
|
|
4
|
+
cloud, no second server. Scan a QR code in the terminal and the phone shows:
|
|
5
|
+
|
|
6
|
+
- slide number, title, next slide and speaker notes (A−/A+ for size)
|
|
7
|
+
- timer (▶ to start, ↺ to reset; otherwise starts with the first navigation) and clock
|
|
8
|
+
- Next / Back, Up / Down for vertical slides, black screen (reveal's pause), jump to any slide
|
|
9
|
+
- swipe over the notes to change slides
|
|
10
|
+
- **Spotlight** – a trackpad like the Logitech Spotlight: touch and everything
|
|
11
|
+
but a circle dims, move and the circle follows, lift and it's gone. "Hold"
|
|
12
|
+
keeps it, S/M/L sets the size. Turns off by itself when the phone
|
|
13
|
+
disconnects.
|
|
14
|
+
|
|
15
|
+
It runs on your existing Vite dev/preview server (or any Connect/Express/
|
|
16
|
+
node:http server) via Server-Sent Events. Only dependency: `qrcode-terminal`.
|
|
17
|
+
|
|
18
|
+
## Try it
|
|
19
|
+
|
|
20
|
+
The repository has a demo deck in two flavours:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
git clone https://github.com/JanHuege/reveal.js-phone-remote.git && cd reveal.js-phone-remote
|
|
24
|
+
npm install
|
|
25
|
+
npm run example # Vite – http://localhost:5173/
|
|
26
|
+
npm run example:node # plain node:http – http://localhost:8000/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open the deck on the laptop and scan the QR code in the terminal with your
|
|
30
|
+
phone. Details: [`examples/vite`](examples/vite/), [`examples/node`](examples/node/).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install reveal.js-phone-remote
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage with Vite
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// vite.config.js
|
|
42
|
+
import { defineConfig } from 'vite';
|
|
43
|
+
import phoneRemote from 'reveal.js-phone-remote/vite';
|
|
44
|
+
|
|
45
|
+
export default defineConfig({
|
|
46
|
+
plugins: [phoneRemote()],
|
|
47
|
+
// Listen on all interfaces so the phone can reach the laptop.
|
|
48
|
+
// '::' = dual stack (IPv4 + IPv6), needed in IPv6-only phone hotspots.
|
|
49
|
+
server: { host: '::' },
|
|
50
|
+
preview: { host: '::' },
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
// main.js
|
|
56
|
+
import Reveal from 'reveal.js';
|
|
57
|
+
import PhoneRemote from 'reveal.js-phone-remote';
|
|
58
|
+
|
|
59
|
+
Reveal.initialize({
|
|
60
|
+
plugins: [PhoneRemote],
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Start `vite` (or `vite build && vite preview`), open the deck on the laptop and
|
|
65
|
+
scan the QR code in the terminal with the phone camera. Both devices must be in
|
|
66
|
+
the same network – a phone hotspot is the safest bet in lecture halls, where
|
|
67
|
+
Wi-Fi often isolates clients.
|
|
68
|
+
|
|
69
|
+
Without the server (static hosting, PDF export, speaker view iframes) the deck
|
|
70
|
+
plugin stays silent.
|
|
71
|
+
|
|
72
|
+
## Usage without Vite
|
|
73
|
+
|
|
74
|
+
See [`examples/node`](examples/node/) for a complete, bundler-free setup.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import http from 'node:http';
|
|
78
|
+
import { createRemote, printConnectInfo } from 'reveal.js-phone-remote/server';
|
|
79
|
+
|
|
80
|
+
const remote = createRemote();
|
|
81
|
+
const server = http.createServer((req, res) =>
|
|
82
|
+
remote.handle(req, res, () => serveYourDeck(req, res)),
|
|
83
|
+
);
|
|
84
|
+
server.listen(8000, '::');
|
|
85
|
+
printConnectInfo(server, remote);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`remote.handle(req, res, next)` is a Connect-style middleware, so
|
|
89
|
+
`app.use(remote.handle)` works with Express and Connect.
|
|
90
|
+
|
|
91
|
+
## Options
|
|
92
|
+
|
|
93
|
+
### Server (`phoneRemote(options)` / `createRemote(options)`)
|
|
94
|
+
|
|
95
|
+
| Option | Default | |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `key` | `$REMOTE_KEY` or random | Access key, embedded in the QR code. Set a fixed one to keep a home-screen bookmark valid across restarts. |
|
|
98
|
+
| `prefix` | `'/_remote'` | URL prefix of all endpoints and the phone page. |
|
|
99
|
+
| `lang` | `'en'` | Phone UI language: `'en'`, `'de'`. |
|
|
100
|
+
| `strings` | `{}` | Override single UI strings, see `src/phone/i18n.js`. |
|
|
101
|
+
| `title` | `'Remote'` | Page title and home-screen name. |
|
|
102
|
+
| `themeColor` | `'#0b1020'` | Browser chrome colour on the phone. |
|
|
103
|
+
| `css` | – | Path to an extra stylesheet for the phone page. |
|
|
104
|
+
| `qr` | `true` | Vite only: print the QR code. |
|
|
105
|
+
|
|
106
|
+
### Deck (`phoneRemote` key in the reveal config)
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
Reveal.initialize({
|
|
110
|
+
plugins: [PhoneRemote],
|
|
111
|
+
phoneRemote: {
|
|
112
|
+
prefix: '/_remote', // must match the server
|
|
113
|
+
slideLabel: 'Slide', // fallback title: "Slide 3"
|
|
114
|
+
title: (slide, index) => …, // custom title extraction, falsy = default
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Default slide title: first `h1`–`h3` of the slide.
|
|
120
|
+
|
|
121
|
+
## Theming
|
|
122
|
+
|
|
123
|
+
The phone page uses CSS custom properties. Override them in a stylesheet
|
|
124
|
+
passed as `css`:
|
|
125
|
+
|
|
126
|
+
```css
|
|
127
|
+
:root {
|
|
128
|
+
--bg: #07102b;
|
|
129
|
+
--panel: #0e1b42;
|
|
130
|
+
--accent: #418fde;
|
|
131
|
+
--highlight: #c0ba00;
|
|
132
|
+
--font: 'Inter', system-ui, sans-serif;
|
|
133
|
+
--radius: 0 14px 0 14px;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
All properties: see the top of `src/phone/remote.css`.
|
|
138
|
+
|
|
139
|
+
The spotlight's dim colour on the deck: `.reveal-spotlight { --spotlight-dim: rgb(0 0 0 / 0.8); }`.
|
|
140
|
+
|
|
141
|
+
## Standalone spotlight
|
|
142
|
+
|
|
143
|
+
The spotlight overlay can be driven by something else too:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { createSpotlight } from 'reveal.js-phone-remote/spotlight';
|
|
147
|
+
|
|
148
|
+
const spot = createSpotlight(Reveal);
|
|
149
|
+
spot.update({ on: true, x: 0.5, y: 0.3, r: 0.14 }); // normalised to the slide
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`x`, `y` in 0..1 of the slide, `r` as fraction of the slide height.
|
|
153
|
+
|
|
154
|
+
## Security
|
|
155
|
+
|
|
156
|
+
Everyone in the same network reaches the server. Commands, state and notes
|
|
157
|
+
require the key, which is rolled at start and only travels in the QR code.
|
|
158
|
+
Plain HTTP – use it in networks you trust, or a phone hotspot.
|
|
159
|
+
|
|
160
|
+
## Notes on iOS
|
|
161
|
+
|
|
162
|
+
- "Add to Home Screen" in Safari turns the page into a full-screen web app.
|
|
163
|
+
- Over `http://` in the LAN there is no Wake Lock API – set Auto-Lock to
|
|
164
|
+
"Never" for the talk.
|
|
165
|
+
- A gyroscope pointer like the original Logitech Spotlight isn't possible:
|
|
166
|
+
iOS only exposes motion sensors to HTTPS pages.
|
|
167
|
+
- In an iPhone hotspot, the QR code uses the Mac's Bonjour name (`….local`):
|
|
168
|
+
the iPhone would route the Mac's global IPv6 address over cellular instead of
|
|
169
|
+
the hotspot. Start the server *after* joining the network.
|
|
170
|
+
|
|
171
|
+
## Protocol
|
|
172
|
+
|
|
173
|
+
| | |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| `GET <prefix>/` | phone page |
|
|
176
|
+
| `GET <prefix>/ping` | deck checks for the server |
|
|
177
|
+
| `GET <prefix>/events?role=deck` | commands to the deck (SSE) |
|
|
178
|
+
| `GET <prefix>/events?role=remote&key=…` | deck state to the phone (SSE) |
|
|
179
|
+
| `POST <prefix>/state` | deck reports its state |
|
|
180
|
+
| `POST <prefix>/command` (`X-Remote-Key`) | `next`, `prev`, `left`, `right`, `up`, `down`, `first`, `last`, `pause`, `goto {h, v}`, `spot {on, x, y, r}` |
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reveal.js-phone-remote",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Control a reveal.js deck from your phone: speaker notes, timer, slide jump and a Logitech-style spotlight. No app, no cloud.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Jan Huege",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/JanHuege/reveal.js-phone-remote.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/JanHuege/reveal.js-phone-remote#readme",
|
|
13
|
+
"bugs": "https://github.com/JanHuege/reveal.js-phone-remote/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"reveal.js",
|
|
16
|
+
"reveal.js-plugin",
|
|
17
|
+
"remote",
|
|
18
|
+
"presenter",
|
|
19
|
+
"spotlight",
|
|
20
|
+
"speaker-notes",
|
|
21
|
+
"vite-plugin"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/deck/plugin.js",
|
|
25
|
+
"./spotlight": "./src/deck/spotlight.js",
|
|
26
|
+
"./server": "./src/server/index.js",
|
|
27
|
+
"./vite": "./src/server/vite.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"example": "npm run dev -w examples/vite",
|
|
38
|
+
"example:preview": "npm run preview -w examples/vite",
|
|
39
|
+
"example:node": "npm start -w examples/node"
|
|
40
|
+
},
|
|
41
|
+
"workspaces": [
|
|
42
|
+
"examples/*"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"qrcode-terminal": "^0.12.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"reveal.js": ">=4",
|
|
49
|
+
"vite": ">=5"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"vite": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// reveal.js plugin – the deck side of the remote.
|
|
2
|
+
//
|
|
3
|
+
// Reports every slide change to the server and runs the remote's commands.
|
|
4
|
+
// Stays silent when the server isn't there (static hosting, PDF export,
|
|
5
|
+
// speaker view).
|
|
6
|
+
//
|
|
7
|
+
// import PhoneRemote from 'reveal.js-phone-remote';
|
|
8
|
+
// Reveal.initialize({ plugins: [PhoneRemote], phoneRemote: { … } });
|
|
9
|
+
|
|
10
|
+
import { createSpotlight } from './spotlight.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
// Must match the server's prefix.
|
|
14
|
+
prefix: '/_remote',
|
|
15
|
+
// Fallback title for slides without h1–h3: "<slideLabel> <n>".
|
|
16
|
+
slideLabel: 'Slide',
|
|
17
|
+
// Custom title extraction: (slide, index) => string | null.
|
|
18
|
+
title: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const REPORT_ON = [
|
|
22
|
+
'slidechanged',
|
|
23
|
+
'fragmentshown',
|
|
24
|
+
'fragmenthidden',
|
|
25
|
+
'paused',
|
|
26
|
+
'resumed',
|
|
27
|
+
'overviewshown',
|
|
28
|
+
'overviewhidden',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export default function PhoneRemote() {
|
|
32
|
+
return {
|
|
33
|
+
id: 'phone-remote',
|
|
34
|
+
init(deck) {
|
|
35
|
+
const options = { ...DEFAULTS, ...deck.getConfig().phoneRemote };
|
|
36
|
+
// Not awaited: an unreachable server must not delay reveal's start.
|
|
37
|
+
if (deck.isReady()) connect(deck, options);
|
|
38
|
+
else deck.on('ready', () => connect(deck, options));
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function connect(deck, options) {
|
|
44
|
+
const { prefix } = options;
|
|
45
|
+
const params = new URLSearchParams(location.search);
|
|
46
|
+
// The speaker view loads the deck in iframes with ?receiver. Those must
|
|
47
|
+
// neither report nor run commands – every command would run twice.
|
|
48
|
+
if (params.has('print-pdf') || params.has('receiver')) return;
|
|
49
|
+
if (window.self !== window.top) return;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const ping = await fetch(`${prefix}/ping`);
|
|
53
|
+
if (!ping.ok) return;
|
|
54
|
+
} catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// At most one report in flight, the snapshot is taken when it leaves.
|
|
59
|
+
// Parallel POSTs can arrive out of order – an older state would then
|
|
60
|
+
// overwrite a newer one on the phone.
|
|
61
|
+
let inFlight = false;
|
|
62
|
+
let queued = false;
|
|
63
|
+
const report = async () => {
|
|
64
|
+
queued = true;
|
|
65
|
+
if (inFlight) return;
|
|
66
|
+
inFlight = true;
|
|
67
|
+
while (queued) {
|
|
68
|
+
queued = false;
|
|
69
|
+
await fetch(`${prefix}/state`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify(snapshot(deck, options)),
|
|
73
|
+
}).catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
inFlight = false;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
REPORT_ON.forEach((type) => deck.on(type, report));
|
|
79
|
+
|
|
80
|
+
const spotlight = createSpotlight(deck);
|
|
81
|
+
|
|
82
|
+
const events = new EventSource(`${prefix}/events?role=deck`);
|
|
83
|
+
// Report after every (re)connect – the server may have restarted.
|
|
84
|
+
events.addEventListener('open', report);
|
|
85
|
+
events.addEventListener('command', (e) => {
|
|
86
|
+
const command = JSON.parse(e.data);
|
|
87
|
+
if (command.cmd === 'spot') spotlight.update(command);
|
|
88
|
+
else run(deck, command);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function run(deck, { cmd, h, v }) {
|
|
93
|
+
if (deck.isOverview() && cmd !== 'pause') deck.toggleOverview(false);
|
|
94
|
+
|
|
95
|
+
switch (cmd) {
|
|
96
|
+
case 'next':
|
|
97
|
+
deck.next();
|
|
98
|
+
break;
|
|
99
|
+
case 'prev':
|
|
100
|
+
deck.prev();
|
|
101
|
+
break;
|
|
102
|
+
// Left/right: fragments first, then the neighbouring main slide –
|
|
103
|
+
// subslides are skipped (that's what up/down are for).
|
|
104
|
+
case 'right':
|
|
105
|
+
deck.right();
|
|
106
|
+
break;
|
|
107
|
+
case 'left':
|
|
108
|
+
deck.left();
|
|
109
|
+
break;
|
|
110
|
+
case 'up':
|
|
111
|
+
deck.up();
|
|
112
|
+
break;
|
|
113
|
+
case 'down':
|
|
114
|
+
deck.down();
|
|
115
|
+
break;
|
|
116
|
+
case 'first':
|
|
117
|
+
deck.slide(0, 0);
|
|
118
|
+
break;
|
|
119
|
+
case 'last':
|
|
120
|
+
deck.slide(deck.getHorizontalSlides().length - 1, 0);
|
|
121
|
+
break;
|
|
122
|
+
case 'pause':
|
|
123
|
+
deck.togglePause();
|
|
124
|
+
break;
|
|
125
|
+
case 'goto':
|
|
126
|
+
deck.slide(Number(h) || 0, Number(v) || 0);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function snapshot(deck, options) {
|
|
132
|
+
const slides = deck.getSlides();
|
|
133
|
+
const current = deck.getCurrentSlide();
|
|
134
|
+
const index = slides.indexOf(current);
|
|
135
|
+
const { h, v, f } = deck.getIndices();
|
|
136
|
+
const titleOf = (slide, i) => options.title?.(slide, i) || defaultTitle(slide, i, options);
|
|
137
|
+
|
|
138
|
+
const main = deck.getHorizontalSlides();
|
|
139
|
+
const nextMain = main[h + 1];
|
|
140
|
+
const nextSlide = nextMain && (nextMain.querySelector(':scope > section') || nextMain);
|
|
141
|
+
|
|
142
|
+
const fragments = current.querySelectorAll('.fragment');
|
|
143
|
+
const shown = current.querySelectorAll('.fragment.visible');
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
h,
|
|
147
|
+
v,
|
|
148
|
+
f,
|
|
149
|
+
index,
|
|
150
|
+
total: slides.length,
|
|
151
|
+
title: titleOf(current, index),
|
|
152
|
+
notes: deck.getSlideNotes(current) || '',
|
|
153
|
+
fragments: fragments.length,
|
|
154
|
+
fragmentsShown: shown.length,
|
|
155
|
+
// "Next" on the phone goes right, so "up next" is the next main slide.
|
|
156
|
+
next: nextSlide ? titleOf(nextSlide, slides.indexOf(nextSlide)) : null,
|
|
157
|
+
paused: deck.isPaused(),
|
|
158
|
+
// Which ways lead somewhere – for the up/down buttons.
|
|
159
|
+
routes: routesOf(current, v),
|
|
160
|
+
aspect: aspectOf(deck),
|
|
161
|
+
// Outline for jumping to a slide.
|
|
162
|
+
outline: slides.map((slide, i) => ({
|
|
163
|
+
...deck.getIndices(slide),
|
|
164
|
+
title: titleOf(slide, i),
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Up/down from the current slide's own stack, so they always match h/v of
|
|
170
|
+
// the same snapshot. deck.availableRoutes() reads reveal's .present classes
|
|
171
|
+
// and turns up/down on for every slide when loop is set.
|
|
172
|
+
function routesOf(slide, v = 0) {
|
|
173
|
+
const stack = slide.parentElement?.closest('section');
|
|
174
|
+
const count = stack ? stack.querySelectorAll(':scope > section').length : 1;
|
|
175
|
+
return { up: v > 0, down: v < count - 1 };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Slide width / height, so the phone's trackpad moves the spot evenly.
|
|
179
|
+
function aspectOf(deck) {
|
|
180
|
+
const { width, height } = deck.getConfig();
|
|
181
|
+
const ratio = Number(width) / Number(height);
|
|
182
|
+
return Number.isFinite(ratio) && ratio > 0 ? ratio : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function defaultTitle(slide, index, { slideLabel }) {
|
|
186
|
+
const heading = slide.querySelector('h1, h2, h3');
|
|
187
|
+
const text = heading?.textContent.replace(/\s+/g, ' ').trim();
|
|
188
|
+
return text || `${slideLabel} ${index + 1}`;
|
|
189
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Styles are injected, so the module works without a CSS-aware bundler.
|
|
2
|
+
// Override the dim colour with `--spotlight-dim` on `.reveal-spotlight`.
|
|
3
|
+
|
|
4
|
+
const FOLLOW = 0.35; // share of the remaining distance per frame
|
|
5
|
+
const STYLE_ID = "reveal.js-phone-remote-spotlight";
|
|
6
|
+
const CSS = `
|
|
7
|
+
.reveal-spotlight {
|
|
8
|
+
position: fixed;
|
|
9
|
+
inset: 0;
|
|
10
|
+
z-index: 100;
|
|
11
|
+
pointer-events: none;
|
|
12
|
+
background: radial-gradient(
|
|
13
|
+
circle at var(--x, 50%) var(--y, 50%),
|
|
14
|
+
transparent calc(var(--r, 150px) - 6px),
|
|
15
|
+
var(--spotlight-dim, rgb(0 0 0 / 0.8)) var(--r, 150px)
|
|
16
|
+
);
|
|
17
|
+
opacity: 0;
|
|
18
|
+
transition: opacity 0.18s ease-out;
|
|
19
|
+
}
|
|
20
|
+
.reveal-spotlight.on {
|
|
21
|
+
opacity: 1;
|
|
22
|
+
}
|
|
23
|
+
@media print {
|
|
24
|
+
.reveal-spotlight {
|
|
25
|
+
display: none;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export function createSpotlight(deck) {
|
|
31
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
32
|
+
const style = document.createElement("style");
|
|
33
|
+
style.id = STYLE_ID;
|
|
34
|
+
style.textContent = CSS;
|
|
35
|
+
document.head.appendChild(style);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const el = document.createElement("div");
|
|
39
|
+
el.className = "reveal-spotlight";
|
|
40
|
+
el.setAttribute("aria-hidden", "true");
|
|
41
|
+
document.body.appendChild(el);
|
|
42
|
+
|
|
43
|
+
const target = { x: 0.5, y: 0.5, r: 0.14 };
|
|
44
|
+
const current = { ...target };
|
|
45
|
+
let visible = false;
|
|
46
|
+
let frame = 0;
|
|
47
|
+
|
|
48
|
+
function paint() {
|
|
49
|
+
const box = deck.getSlidesElement().getBoundingClientRect();
|
|
50
|
+
const x = box.left + current.x * box.width;
|
|
51
|
+
const y = box.top + current.y * box.height;
|
|
52
|
+
const r = current.r * box.height;
|
|
53
|
+
el.style.setProperty("--x", `${x}px`);
|
|
54
|
+
el.style.setProperty("--y", `${y}px`);
|
|
55
|
+
el.style.setProperty("--r", `${r}px`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function step() {
|
|
59
|
+
let moving = false;
|
|
60
|
+
for (const k of ["x", "y", "r"]) {
|
|
61
|
+
const d = target[k] - current[k];
|
|
62
|
+
if (Math.abs(d) > 0.0005) {
|
|
63
|
+
current[k] += d * FOLLOW;
|
|
64
|
+
moving = true;
|
|
65
|
+
} else {
|
|
66
|
+
current[k] = target[k];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
paint();
|
|
70
|
+
frame = moving ? requestAnimationFrame(step) : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function update({ on, x, y, r }) {
|
|
74
|
+
if (Number.isFinite(x)) target.x = clamp(x);
|
|
75
|
+
if (Number.isFinite(y)) target.y = clamp(y);
|
|
76
|
+
if (Number.isFinite(r)) target.r = Math.min(0.5, Math.max(0.03, r));
|
|
77
|
+
|
|
78
|
+
const show = on !== false;
|
|
79
|
+
if (show && !visible) {
|
|
80
|
+
// Don't fly across the slide when fading in.
|
|
81
|
+
Object.assign(current, target);
|
|
82
|
+
paint();
|
|
83
|
+
}
|
|
84
|
+
visible = show;
|
|
85
|
+
el.classList.toggle("on", visible);
|
|
86
|
+
|
|
87
|
+
if (!frame) frame = requestAnimationFrame(step);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function destroy() {
|
|
91
|
+
cancelAnimationFrame(frame);
|
|
92
|
+
window.removeEventListener("resize", paint);
|
|
93
|
+
el.remove();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
window.addEventListener("resize", paint);
|
|
97
|
+
return { update, destroy };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const clamp = (v) => Math.min(1, Math.max(0, v));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const STRINGS = {
|
|
2
|
+
en: {
|
|
3
|
+
connection: "Connection",
|
|
4
|
+
connecting: "Connecting …",
|
|
5
|
+
upNext: "Up next:",
|
|
6
|
+
end: "End",
|
|
7
|
+
noNotes: "No notes.",
|
|
8
|
+
step: "Step {shown} / {total}",
|
|
9
|
+
slides: "Slides",
|
|
10
|
+
spot: "Spot",
|
|
11
|
+
black: "Black",
|
|
12
|
+
show: "Show",
|
|
13
|
+
prev: "Back",
|
|
14
|
+
next: "Next",
|
|
15
|
+
up: "Up",
|
|
16
|
+
down: "Down",
|
|
17
|
+
notesSmaller: "Smaller notes",
|
|
18
|
+
notesLarger: "Larger notes",
|
|
19
|
+
timerToggle: "Start/stop timer",
|
|
20
|
+
timerReset: "Reset timer",
|
|
21
|
+
jumpTo: "Jump to",
|
|
22
|
+
done: "Done",
|
|
23
|
+
spotlight: "Spotlight",
|
|
24
|
+
spotHint: "Touch – the spot appears.\nMove – the spot follows.",
|
|
25
|
+
spotSize: "Spot size",
|
|
26
|
+
hold: "Hold",
|
|
27
|
+
noAccess: "No access – scan the QR code in the terminal again.",
|
|
28
|
+
reconnecting: "Connection lost – reconnecting …",
|
|
29
|
+
unreachable: "Server unreachable.",
|
|
30
|
+
noDeck: "Presentation not connected – open the deck in the browser.",
|
|
31
|
+
},
|
|
32
|
+
de: {
|
|
33
|
+
connection: "Verbindung",
|
|
34
|
+
connecting: "Verbinde …",
|
|
35
|
+
upNext: "Als Nächstes:",
|
|
36
|
+
end: "Ende",
|
|
37
|
+
noNotes: "Keine Notizen.",
|
|
38
|
+
step: "Schritt {shown} / {total}",
|
|
39
|
+
slides: "Folien",
|
|
40
|
+
spot: "Spot",
|
|
41
|
+
black: "Schwarz",
|
|
42
|
+
show: "Zeigen",
|
|
43
|
+
prev: "Zurück",
|
|
44
|
+
next: "Weiter",
|
|
45
|
+
up: "Hoch",
|
|
46
|
+
down: "Runter",
|
|
47
|
+
notesSmaller: "Notizen kleiner",
|
|
48
|
+
notesLarger: "Notizen größer",
|
|
49
|
+
timerToggle: "Timer starten/anhalten",
|
|
50
|
+
timerReset: "Timer zurücksetzen",
|
|
51
|
+
jumpTo: "Springen zu",
|
|
52
|
+
done: "Fertig",
|
|
53
|
+
spotlight: "Spotlight",
|
|
54
|
+
spotHint:
|
|
55
|
+
"Finger auflegen – der Spot erscheint.\nBewegen – der Spot folgt.",
|
|
56
|
+
spotSize: "Spotgröße",
|
|
57
|
+
hold: "Halten",
|
|
58
|
+
noAccess: "Kein Zugriff – QR-Code im Terminal neu scannen.",
|
|
59
|
+
reconnecting: "Verbindung unterbrochen – verbinde neu …",
|
|
60
|
+
unreachable: "Server nicht erreichbar.",
|
|
61
|
+
noDeck: "Präsentation nicht verbunden – Deck im Browser öffnen.",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function translator(lang, overrides = {}) {
|
|
66
|
+
const base = STRINGS[lang] || STRINGS[lang?.split("-")[0]] || STRINGS.en;
|
|
67
|
+
const strings = { ...STRINGS.en, ...base, ...overrides };
|
|
68
|
+
return (id, vars = {}) =>
|
|
69
|
+
strings[id].replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function translate(root, t) {
|
|
73
|
+
root.querySelectorAll("[data-i18n]").forEach((el) => {
|
|
74
|
+
el.textContent = t(el.dataset.i18n);
|
|
75
|
+
});
|
|
76
|
+
root.querySelectorAll("[data-i18n-label]").forEach((el) => {
|
|
77
|
+
el.setAttribute("aria-label", t(el.dataset.i18nLabel));
|
|
78
|
+
});
|
|
79
|
+
root.querySelectorAll("[data-i18n-title]").forEach((el) => {
|
|
80
|
+
el.title = t(el.dataset.i18nTitle);
|
|
81
|
+
});
|
|
82
|
+
}
|