mdeck 2.3.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/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- package/src/runtime/themeLoader.js +68 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Audience interaction for slide components: `import { useRoom } from 'mdeck/live'`.
|
|
2
|
+
//
|
|
3
|
+
// Relay mode: the server connects the presenter's screen and the phones
|
|
4
|
+
// and never needs the deck. Every activity in a deck shares one join link,
|
|
5
|
+
// <server>/<code>. The presenter's screen announces the activity on the current
|
|
6
|
+
// slide together with what the phone should show (a component's `phone`
|
|
7
|
+
// description), the deck's look and the words in its language; the room
|
|
8
|
+
// server's answer page shows that. On the slides, `useRoom` follows the
|
|
9
|
+
// answers.
|
|
10
|
+
import { useCallback, useEffect, useState } from 'preact/hooks'
|
|
11
|
+
import { sessionCode } from './code.js'
|
|
12
|
+
import { pairToken } from './pairing.js'
|
|
13
|
+
import { devPath } from '../core/devPath.js'
|
|
14
|
+
import { t, deckLanguage } from '../core/labels.js'
|
|
15
|
+
|
|
16
|
+
// The QR code component the built-in poll uses, for your own activities.
|
|
17
|
+
export { default as QrCode } from '../components/QrCode.jsx'
|
|
18
|
+
|
|
19
|
+
let settings = { server: null, code: '000000' }
|
|
20
|
+
|
|
21
|
+
// Called by the runtime with the deck settings.
|
|
22
|
+
// `mdeck run --server <address>` and MDECK_SERVER name the server without the
|
|
23
|
+
// deck; the dev server passes it in as __MDECK_SERVER__ and it wins.
|
|
24
|
+
const givenServer = typeof __MDECK_SERVER__ === 'string' ? __MDECK_SERVER__ : null
|
|
25
|
+
|
|
26
|
+
export function configureLive(deckConfig = {}) {
|
|
27
|
+
const server = givenServer ?? deckConfig.server
|
|
28
|
+
settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig) }
|
|
29
|
+
infoRequest = null
|
|
30
|
+
takeKeyFromAddress()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The deck's session code, the last part of its join link. */
|
|
34
|
+
export const liveCode = () => settings.code
|
|
35
|
+
|
|
36
|
+
const proxyControls = typeof __MDECK_PROXY_CONTROLS__ !== 'undefined' && __MDECK_PROXY_CONTROLS__
|
|
37
|
+
const controlBase = () => proxyControls ? new URL(devPath('__mdeck/live'), window.location.origin).href : serverBase()
|
|
38
|
+
const controlRoomPath = room => `${controlBase()}/rooms/${encodeURIComponent(`${settings.code}.${room}`)}`
|
|
39
|
+
const controlSessionPath = () => `${controlBase()}/rooms/${encodeURIComponent(settings.code)}`
|
|
40
|
+
|
|
41
|
+
const page = () => new URL(window.location.href)
|
|
42
|
+
// Without a `server` setting, the server built into `mdeck run`.
|
|
43
|
+
const serverBase = () => settings.server ?? new URL(devPath('__mdeck/live'), window.location.origin).href
|
|
44
|
+
const roomPath = room => `${serverBase()}/rooms/${encodeURIComponent(`${settings.code}.${room}`)}`
|
|
45
|
+
const sessionPath = () => `${serverBase()}/rooms/${encodeURIComponent(settings.code)}`
|
|
46
|
+
|
|
47
|
+
// Which rooms keep a live connection: those on the current slide, set by the
|
|
48
|
+
// runtime for each deck it shows. Browsers allow only six connections per
|
|
49
|
+
// server, shared by all tabs, so a deck must not hold one per activity.
|
|
50
|
+
// null means every room is live (Read mode, print, pages without slides).
|
|
51
|
+
let activeRooms = null
|
|
52
|
+
const activeListeners = new Set()
|
|
53
|
+
export function setActiveRooms(rooms) {
|
|
54
|
+
activeRooms = rooms == null ? null : new Set(rooms.map(String))
|
|
55
|
+
for (const listener of activeListeners) listener()
|
|
56
|
+
}
|
|
57
|
+
function useActive(room) {
|
|
58
|
+
const [, redraw] = useState(0)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const listener = () => redraw(n => n + 1)
|
|
61
|
+
activeListeners.add(listener)
|
|
62
|
+
return () => activeListeners.delete(listener)
|
|
63
|
+
}, [])
|
|
64
|
+
return activeRooms == null || activeRooms.has(String(room))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The presenter unlocks a standalone server once with ?serverkey=… in the
|
|
68
|
+
// address. The code is kept in this browser, where the presenter view's other
|
|
69
|
+
// windows find it, and taken out of the address bar at once, so it never shows
|
|
70
|
+
// on a projector.
|
|
71
|
+
let givenKey = null
|
|
72
|
+
function takeKeyFromAddress() {
|
|
73
|
+
const url = page()
|
|
74
|
+
const given = url.searchParams.get('serverkey')
|
|
75
|
+
if (!given) return
|
|
76
|
+
givenKey = given
|
|
77
|
+
try { localStorage.setItem(`mdeck-server-key:${serverBase()}`, given) } catch {}
|
|
78
|
+
url.searchParams.delete('serverkey')
|
|
79
|
+
try { history.replaceState(history.state, '', url) } catch {}
|
|
80
|
+
}
|
|
81
|
+
// The server built into `mdeck run` also lets a paired iPad in, and so
|
|
82
|
+
// does the server that relays `mdeck run --server`: this page then comes
|
|
83
|
+
// from it, so it is the same origin. The token is never sent to another server.
|
|
84
|
+
function ownServer() {
|
|
85
|
+
if (!settings.server) return true
|
|
86
|
+
try { return new URL(settings.server).origin === window.location.origin } catch { return false }
|
|
87
|
+
}
|
|
88
|
+
function presenterKey() {
|
|
89
|
+
// A relayed page always uses its own pairing, even if this origin once
|
|
90
|
+
// stored a master key for a different presentation.
|
|
91
|
+
if (/^\/t\/[^/]+\/$/.test(devPath('')) || proxyControls || (import.meta.env?.DEV && ownServer())) return pairToken()
|
|
92
|
+
let key = givenKey
|
|
93
|
+
try { key = localStorage.getItem(`mdeck-server-key:${serverBase()}`) ?? givenKey } catch {}
|
|
94
|
+
return key ?? (ownServer() ? pairToken() : null)
|
|
95
|
+
}
|
|
96
|
+
const withKey = (headers = {}) => { const key = presenterKey(); return key ? { ...headers, Authorization: `Bearer ${key}` } : headers }
|
|
97
|
+
|
|
98
|
+
// Only an answer is kept; after a failure the next call asks again, so a room
|
|
99
|
+
// server that starts later is found. `reachable` is false when it did not answer.
|
|
100
|
+
let infoRequest = null
|
|
101
|
+
function serverInfo() {
|
|
102
|
+
infoRequest ??= fetch(`${controlBase()}/info?session=${encodeURIComponent(settings.code)}`, { headers: withKey() })
|
|
103
|
+
.then(response => { if (!response.ok) throw new Error(String(response.status)); return response.json() })
|
|
104
|
+
.then(info => ({ ...info, reachable: true }))
|
|
105
|
+
.catch(() => { infoRequest = null; return { reachable: false } })
|
|
106
|
+
return infoRequest
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The join link phones can open: the `server` setting, or `mdeck run --network`'s
|
|
110
|
+
// network address. null when only this computer can reach the server;
|
|
111
|
+
// `localJoinUrl` then still opens the answer page here, for trying it out.
|
|
112
|
+
function joinUrl(info) {
|
|
113
|
+
if (settings.server) return `${settings.server}/${settings.code}`
|
|
114
|
+
return info.network ? new URL(`__mdeck/live/${settings.code}`, info.network).href : null
|
|
115
|
+
}
|
|
116
|
+
const localJoinUrl = () => `${serverBase()}/${settings.code}`
|
|
117
|
+
// Until the server has answered or failed, `known` is false and activities
|
|
118
|
+
// show no join code, so a quick capture never shows a passing warning.
|
|
119
|
+
const reach = info => ({ known: info.reachable != null, offline: info.reachable === false })
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Follow a room's answers on a slide.
|
|
123
|
+
* messages every answer so far: { n, at, from, data: { value } }
|
|
124
|
+
* joinUrl the deck's join link for the QR code, or null if phones cannot
|
|
125
|
+
* reach the server; localJoinUrl works on this computer
|
|
126
|
+
* canReset / reset() presenter only
|
|
127
|
+
* connected false until the live stream is open
|
|
128
|
+
* offline no room server answered: a PDF, a file sent to readers or a
|
|
129
|
+
* build hosted without one. Activities then show their question
|
|
130
|
+
* as a record of the talk, without a join code.
|
|
131
|
+
* Off the current slide the room keeps its last messages but disconnects.
|
|
132
|
+
*/
|
|
133
|
+
export function useRoom(room) {
|
|
134
|
+
const live = useActive(room)
|
|
135
|
+
const [messages, setMessages] = useState([])
|
|
136
|
+
const [connected, setConnected] = useState(false)
|
|
137
|
+
const [info, setInfo] = useState({})
|
|
138
|
+
|
|
139
|
+
useEffect(() => { serverInfo().then(setInfo) }, [])
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!live || !room || typeof EventSource === 'undefined') return
|
|
143
|
+
const source = new EventSource(`${roomPath(room)}/events`)
|
|
144
|
+
source.addEventListener('open', () => setConnected(true))
|
|
145
|
+
source.addEventListener('error', () => setConnected(false))
|
|
146
|
+
source.addEventListener('snapshot', event => setMessages(JSON.parse(event.data).messages))
|
|
147
|
+
source.addEventListener('message', event => { const message = JSON.parse(event.data); setMessages(list => list.some(m => m.n === message.n) ? list : [...list, message]) })
|
|
148
|
+
source.addEventListener('reset', () => setMessages([]))
|
|
149
|
+
return () => { source.close(); setConnected(false) }
|
|
150
|
+
}, [room, live])
|
|
151
|
+
|
|
152
|
+
const reset = useCallback(async () => {
|
|
153
|
+
const response = await fetch(`${controlRoomPath(room)}/reset`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: '{}' })
|
|
154
|
+
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? 'Could not reset')
|
|
155
|
+
}, [room])
|
|
156
|
+
|
|
157
|
+
return { messages, reset, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
|
|
161
|
+
export function useJoinLink() {
|
|
162
|
+
const [info, setInfo] = useState({})
|
|
163
|
+
useEffect(() => { serverInfo().then(setInfo) }, [])
|
|
164
|
+
return { joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The latest answer of each device, e.g. to count votes that can be changed. */
|
|
168
|
+
export function latestByDevice(messages) {
|
|
169
|
+
const latest = new Map()
|
|
170
|
+
for (const message of messages) latest.set(message.from ?? `#${message.n}`, message)
|
|
171
|
+
return [...latest.values()]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── The presenter's screen ──────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
// The deck's look as the answer page needs it: theme variables, font
|
|
177
|
+
// stylesheets and the style of a slide heading, read from the page that shows
|
|
178
|
+
// the slides (the presenter's current-slide frame, or the deck itself).
|
|
179
|
+
const LOOK_TOKENS = ['--bg', '--ink', '--ink-soft', '--muted', '--rule', '--surface', '--accent', '--accent-2', '--on-accent', '--font-body', '--font-display']
|
|
180
|
+
let lookSource = () => document
|
|
181
|
+
export function setLookSource(source) { lookSource = source }
|
|
182
|
+
export function captureLook(doc = lookSource()) {
|
|
183
|
+
if (!doc?.documentElement) return null
|
|
184
|
+
const style = getComputedStyle(doc.documentElement)
|
|
185
|
+
const tokens = Object.fromEntries(LOOK_TOKENS.map(name => [name, style.getPropertyValue(name).trim()]).filter(([, value]) => value))
|
|
186
|
+
const fonts = [...doc.querySelectorAll('link[data-deck-theme-font]')].map(link => link.href)
|
|
187
|
+
// A frame that has not loaded its slides yet has no theme: send nothing, so
|
|
188
|
+
// phones keep the look they have.
|
|
189
|
+
if (!tokens['--bg'] && !tokens['--font-body']) return null
|
|
190
|
+
const h1 = doc.querySelector('.slide-body h1')
|
|
191
|
+
const hs = h1 && doc.defaultView.getComputedStyle(h1)
|
|
192
|
+
// Letter spacing relative to the font size, so a slide heading's spacing
|
|
193
|
+
// fits the much smaller heading on a phone.
|
|
194
|
+
const spacing = hs && (hs.letterSpacing === 'normal' ? 'normal' : `${(parseFloat(hs.letterSpacing) / parseFloat(hs.fontSize)).toFixed(3)}em`)
|
|
195
|
+
const heading = hs ? { family: hs.fontFamily, weight: hs.fontWeight, spacing, transform: hs.textTransform } : null
|
|
196
|
+
return { tokens, fonts, heading }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The words the answer page shows, in the deck's language.
|
|
200
|
+
const phoneWords = () => ({ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent') })
|
|
201
|
+
|
|
202
|
+
// The presenter's screen announces the activity on the current slide and
|
|
203
|
+
// repeats it every few seconds, so phones that join late and a server
|
|
204
|
+
// that restarted catch up; the server passes on only changes. Only the
|
|
205
|
+
// presenter may: `steering` says whether this screen reaches the phones.
|
|
206
|
+
const REPEAT_MS = 4000
|
|
207
|
+
// This window, so the server can tell its repeats from new changes.
|
|
208
|
+
const SCREEN = Math.random().toString(36).slice(2, 10)
|
|
209
|
+
let current = { room: null, activity: null, title: '', at: 0, screen: SCREEN }, repeat = null
|
|
210
|
+
let steering = null
|
|
211
|
+
let lastLook = null
|
|
212
|
+
const steeringListeners = new Set()
|
|
213
|
+
function report(next) {
|
|
214
|
+
if (steering && steering.ok === next.ok && steering.reason === next.reason) return
|
|
215
|
+
steering = next
|
|
216
|
+
for (const listener of steeringListeners) listener(next)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A presenter view outranks other deck windows in the same browser: while it
|
|
220
|
+
// runs, they stay quiet, so two screens on different slides or looks do not
|
|
221
|
+
// make the phones flip between them.
|
|
222
|
+
let role = 'screen'
|
|
223
|
+
export function actAsPresenter() { role = 'presenter' }
|
|
224
|
+
const presenterMark = () => `mdeck-live-presenter:${settings.code}`
|
|
225
|
+
function outranked() {
|
|
226
|
+
try {
|
|
227
|
+
if (role === 'presenter') { localStorage.setItem(presenterMark(), String(Date.now())); return false }
|
|
228
|
+
return Date.now() - Number(localStorage.getItem(presenterMark()) ?? 0) < REPEAT_MS * 2.5
|
|
229
|
+
} catch { return false }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function sendCurrent() {
|
|
233
|
+
if (outranked()) return
|
|
234
|
+
const info = await serverInfo()
|
|
235
|
+
if (!info.reachable) return report({ ok: false, reason: 'unreachable' })
|
|
236
|
+
if (!info.canReset) return report({ ok: false, reason: presenterKey() ? 'wrong-code' : 'no-code' })
|
|
237
|
+
const look = captureLook()
|
|
238
|
+
if (look) lastLook = look
|
|
239
|
+
const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords() }
|
|
240
|
+
try {
|
|
241
|
+
const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
|
|
242
|
+
const body = response.ok ? await response.json().catch(() => ({})) : {}
|
|
243
|
+
report(!response.ok ? { ok: false, reason: response.status === 403 ? 'wrong-code' : 'unreachable' } : body.ignored ? { ok: false, reason: 'other-screen' } : { ok: true })
|
|
244
|
+
} catch { report({ ok: false, reason: 'unreachable' }) }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The activity on the current slide: { room, activity, title }, where
|
|
249
|
+
* `activity` is what the phones show ({ type: 'choice' | 'text' | 'scale', … }),
|
|
250
|
+
* or nothing between activities.
|
|
251
|
+
*/
|
|
252
|
+
export function announce({ room = null, activity = null, title = '', initial = false } = {}) {
|
|
253
|
+
// `at` marks this change; the server keeps the latest change of all
|
|
254
|
+
// screens. A screen that just opened says 0, so opening a tab to look at
|
|
255
|
+
// the deck does not take the phones away from the presenter.
|
|
256
|
+
current = { room, activity, title, at: initial ? 0 : Date.now(), screen: SCREEN }
|
|
257
|
+
sendCurrent()
|
|
258
|
+
repeat ??= setInterval(sendCurrent, REPEAT_MS)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The deck's stage room, which carries the presenter's position and live ink
|
|
263
|
+
* to screens on other devices (src/runtime/ink/room.js).
|
|
264
|
+
*/
|
|
265
|
+
// The stage room's own `info`: with a standalone server behind `mdeck run`,
|
|
266
|
+
// the stage room stays on `mdeck run` (src/live/server.js), so it asks there.
|
|
267
|
+
let stageRequest = null
|
|
268
|
+
function stageInfo() {
|
|
269
|
+
if (!proxyControls) return serverInfo()
|
|
270
|
+
stageRequest ??= fetch(`${controlBase()}/info?session=${encodeURIComponent(settings.code)}&stage=1`, { headers: withKey() })
|
|
271
|
+
.then(response => { if (!response.ok) throw new Error(String(response.status)); return response.json() })
|
|
272
|
+
.then(info => ({ ...info, reachable: true }))
|
|
273
|
+
.catch(() => { stageRequest = null; return { reachable: false } })
|
|
274
|
+
return stageRequest
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function stageRoom() {
|
|
278
|
+
return { url: `${controlSessionPath()}.stage`, headers: withKey, info: stageInfo, screen: SCREEN }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** For the presenter view: null before the first announcement, else { ok, reason }. */
|
|
282
|
+
export function useSteering() {
|
|
283
|
+
const [state, setState] = useState(steering)
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
steeringListeners.add(setState)
|
|
286
|
+
setState(steering)
|
|
287
|
+
return () => steeringListeners.delete(setState)
|
|
288
|
+
}, [])
|
|
289
|
+
return state
|
|
290
|
+
}
|
package/src/live/code.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A deck's session code: six digits that name its rooms on the server
|
|
2
|
+
// and end its join link (`<server>/482113`). It is derived from the deck, so
|
|
3
|
+
// the presenter's screen, the QR code on the slides and the launch page agree
|
|
4
|
+
// without asking the server. `session.code` sets it; otherwise `session.id` or the
|
|
5
|
+
// title picks it.
|
|
6
|
+
|
|
7
|
+
export const slug = text => String(text ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
|
8
|
+
|
|
9
|
+
export const CODE_RE = /^\d{4,8}$/
|
|
10
|
+
|
|
11
|
+
export function sessionCode(deckConfig = {}) {
|
|
12
|
+
const session = deckConfig.session ?? {}
|
|
13
|
+
if (session.code != null && CODE_RE.test(String(session.code))) return String(session.code)
|
|
14
|
+
const name = slug(session.id ?? deckConfig.meta?.title) || 'deck'
|
|
15
|
+
// FNV-1a, then six digits that never start with 0.
|
|
16
|
+
let hash = 0x811c9dc5
|
|
17
|
+
for (const char of name) hash = Math.imul(hash ^ char.codePointAt(0), 0x01000193) >>> 0
|
|
18
|
+
return String(100000 + (hash % 900000))
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Only the current slide's activities keep a live connection; while printing,
|
|
2
|
+
// all of them do. Returns a function that stops following.
|
|
3
|
+
import { setActiveRooms } from './client.js'
|
|
4
|
+
import { roomsOnSlide } from './roomTag.js'
|
|
5
|
+
|
|
6
|
+
export function followActiveRooms(stage, slides) {
|
|
7
|
+
if (!stage || !slides.some(slide => roomsOnSlide(slide).length)) return () => {}
|
|
8
|
+
const update = () => setActiveRooms(stage.printing ? null : roomsOnSlide(slides[stage.index]))
|
|
9
|
+
update()
|
|
10
|
+
stage.addEventListener('slidechange', update)
|
|
11
|
+
stage.addEventListener('printchange', update)
|
|
12
|
+
return () => {
|
|
13
|
+
stage.removeEventListener('slidechange', update)
|
|
14
|
+
stage.removeEventListener('printchange', update)
|
|
15
|
+
setActiveRooms(null)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The browser side of pairing with `mdeck run` (src/build/pairing.js): a
|
|
2
|
+
// device that opened the launch page's QR code trades the one-time token in
|
|
3
|
+
// the address for a token of its own, kept in this browser for this server.
|
|
4
|
+
import { devPath } from '../core/devPath.js'
|
|
5
|
+
|
|
6
|
+
export const pairStorageKey = (origin, base) => `mdeck-pair:${origin}${base}`
|
|
7
|
+
const storageKey = () => pairStorageKey(location.origin, devPath(''))
|
|
8
|
+
|
|
9
|
+
export function pairToken() {
|
|
10
|
+
try { return localStorage.getItem(storageKey()) } catch { return null }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Authorization for a paired device, or nothing. */
|
|
14
|
+
export const pairHeaders = (headers = {}) => { const token = pairToken(); return token ? { ...headers, Authorization: `Bearer ${token}` } : headers }
|
|
15
|
+
|
|
16
|
+
/** Claims `?pair=…` if the address has one; resolves to whether it worked. */
|
|
17
|
+
export async function claimPairing() {
|
|
18
|
+
const url = new URL(location.href)
|
|
19
|
+
const offer = url.searchParams.get('pair')
|
|
20
|
+
if (!offer) return false
|
|
21
|
+
// Out of the address at once, so it is not bookmarked or shown.
|
|
22
|
+
url.searchParams.delete('pair')
|
|
23
|
+
try { history.replaceState(history.state, '', url) } catch {}
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(devPath('__mdeck/pair/claim'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: offer }) })
|
|
26
|
+
const body = await response.json().catch(() => ({}))
|
|
27
|
+
if (!response.ok) { console.warn('mdeck: pairing failed:', body.error ?? response.status); return false }
|
|
28
|
+
localStorage.setItem(storageKey(), body.token)
|
|
29
|
+
return true
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.warn('mdeck: pairing failed:', error.message)
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Who is connected to a deck: each presenter, audience and deck window says
|
|
2
|
+
// what it is when it connects to the stage room, and the answer page counts
|
|
3
|
+
// as a phone. The server tells windows that ask (?presence=1) who is there;
|
|
4
|
+
// see src/live/rooms.js and src/components/ConnectedViews.jsx.
|
|
5
|
+
|
|
6
|
+
/** This device, roughly: an iPad says it is a Mac, but has a touch screen. */
|
|
7
|
+
export function deviceKind(nav = typeof navigator === 'undefined' ? {} : navigator) {
|
|
8
|
+
const agent = nav.userAgent ?? ''
|
|
9
|
+
if (/iPad/.test(agent) || (/Macintosh/.test(agent) && nav.maxTouchPoints > 1)) return 'ipad'
|
|
10
|
+
if (/iPhone|iPod|Android.*Mobile/.test(agent)) return 'phone'
|
|
11
|
+
if (/Android|Tablet/.test(agent)) return 'tablet'
|
|
12
|
+
return 'computer'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The query that counts this window as `view`, and asks for presence too. */
|
|
16
|
+
export function presenceQuery({ view = null, listen = false } = {}) {
|
|
17
|
+
const query = new URLSearchParams()
|
|
18
|
+
if (view) { query.set('view', view); query.set('device', deviceKind()) }
|
|
19
|
+
if (listen) query.set('presence', '1')
|
|
20
|
+
return query.toString()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEVICE_NAME = { computer: 'a computer', ipad: 'an iPad', tablet: 'a tablet', phone: 'a phone' }
|
|
24
|
+
const list = words => words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`
|
|
25
|
+
|
|
26
|
+
/** The devices of some connected views, as words: "an iPad and 2 computers". */
|
|
27
|
+
export function devicesText(views) {
|
|
28
|
+
const counts = {}
|
|
29
|
+
for (const entry of views) counts[entry.device] = (counts[entry.device] ?? 0) + 1
|
|
30
|
+
return list(Object.entries(counts).map(([device, n]) => n === 1 ? DEVICE_NAME[device] : `${n} ${device === 'ipad' ? 'iPads' : `${device}s`}`))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const phonesText = n => `${n} phone${n === 1 ? '' : 's'}`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Finds the component tag a phone should see: the one whose `room` attribute
|
|
2
|
+
// matches, with its content if it has any.
|
|
3
|
+
const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
4
|
+
|
|
5
|
+
// Code is not an activity: fenced blocks and `inline code` are blanked out
|
|
6
|
+
// (keeping every other character where it was) before searching.
|
|
7
|
+
const blank = text => text.replace(/[^\n]/g, ' ')
|
|
8
|
+
export function maskCode(source = '') {
|
|
9
|
+
return source.replace(/^(```+|~~~+)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, blank).replace(/`[^`\n]+`/g, blank)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Every activity in a source, in order: [{ tag, room }].
|
|
13
|
+
export function roomsIn(source = '') {
|
|
14
|
+
return [...maskCode(source).matchAll(/<([a-z][a-z0-9-]*)\b[^>]*?\broom\s*=\s*(["'])([^"']+)\2/gi)].map(match => ({ tag: match[1].toLowerCase(), room: match[3] }))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The rooms on one slide, from its body and its named regions (the body is
|
|
18
|
+
// also a region, so each room is listed once).
|
|
19
|
+
export function roomsOnSlide(slide) {
|
|
20
|
+
if (!slide) return []
|
|
21
|
+
const rooms = [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')].flatMap(roomsIn).map(found => found.room)
|
|
22
|
+
return [...new Set(rooms)]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The heading of the slide that holds `room` (what the audience saw), else
|
|
26
|
+
// its `title:` setting, or ''.
|
|
27
|
+
export function slideTitleFor(deck, room) {
|
|
28
|
+
const slide = deck.slides.find(slide => findRoomTag(slide.content ?? '', room) || Object.values(slide.regions ?? {}).some(region => findRoomTag(region.content ?? '', room)))
|
|
29
|
+
return slide?.content?.match(/^\s*#{1,3}\s+(.+?)\s*#*\s*$/m)?.[1]?.replace(/[*_`]/g, '') ?? slide?.meta.title ?? ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function findRoomTag(source, room) {
|
|
33
|
+
const open = new RegExp(`<([a-z][a-z0-9-]*)\\b[^>]*?\\broom\\s*=\\s*(["'])${escape(room)}\\2[^>]*>`, 'i').exec(maskCode(source))
|
|
34
|
+
if (!open) return null
|
|
35
|
+
if (open[0].endsWith('/>')) return open[0]
|
|
36
|
+
const close = source.indexOf(`</${open[1]}>`, open.index + open[0].length)
|
|
37
|
+
return close < 0 ? open[0] : source.slice(open.index, close + open[1].length + 3)
|
|
38
|
+
}
|