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,863 @@
|
|
|
1
|
+
import { validatePageUrl } from '../core/urls.js'
|
|
2
|
+
import { h, render } from 'preact'
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
4
|
+
import { marked } from 'marked'
|
|
5
|
+
import deckFileContent from 'virtual:slides'
|
|
6
|
+
import { parseSlides } from '../core/parseSlides'
|
|
7
|
+
import { validateDeck } from '../core/validateDeck'
|
|
8
|
+
import { loadTheme, setExtensionOverrides, THEME_NAMES, THEME_METAS, PALETTES } from './themeLoader'
|
|
9
|
+
import { resolvePalette } from '../extensions/appearance.js'
|
|
10
|
+
import { palettesFor } from '../extensions/tokens.js'
|
|
11
|
+
import { S, PaletteSwatches } from './chrome.jsx'
|
|
12
|
+
import { SlideErrorBoundary } from './SlideErrorBoundary.jsx'
|
|
13
|
+
import { createEditorBridge } from './editorBridge.js'
|
|
14
|
+
import { ReaderView } from './ReaderView.jsx'
|
|
15
|
+
import { configureLive, announce, setLookSource, actAsPresenter, useSteering } from '../live/client.js'
|
|
16
|
+
import { registry } from './registry'
|
|
17
|
+
import { followActiveRooms } from '../live/follow.js'
|
|
18
|
+
import { createOrder } from './syncOrder.js'
|
|
19
|
+
import { fitDeck } from './fit.js'
|
|
20
|
+
import { attachInk, watchInk } from './ink/attach.js'
|
|
21
|
+
import { roomTransport, followStageRoom, announceStagePosition } from './ink/room.js'
|
|
22
|
+
import { claimPairing } from '../live/pairing.js'
|
|
23
|
+
import { strokePath } from '../core/ink.js'
|
|
24
|
+
import { inkFileName } from 'virtual:deck-ink'
|
|
25
|
+
import { roomsIn, roomsOnSlide, findRoomTag, slideTitleFor } from '../live/roomTag.js'
|
|
26
|
+
import './reader.css'
|
|
27
|
+
import { SlideRenderer, manifests } from '../layouts/renderSlide'
|
|
28
|
+
import { setCalloutLabels } from './markedSetup'
|
|
29
|
+
import { setDeckLanguage, deckLanguage, stageLabels, t } from '../core/labels.js'
|
|
30
|
+
import { Icon } from '../components/Icon.jsx'
|
|
31
|
+
import { ConnectedViews } from '../components/ConnectedViews.jsx'
|
|
32
|
+
import './deck-stage.js'
|
|
33
|
+
|
|
34
|
+
const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
|
|
35
|
+
|
|
36
|
+
// One address parameter selects the view: ?view=deck, reader, presenter or audience.
|
|
37
|
+
export function requestedView(url, fallback = 'deck') {
|
|
38
|
+
return url.searchParams.get('view') ?? fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Scope controls to this file and presenter session, including separate tabs.
|
|
42
|
+
const controlUrl = new URL(window.location.href)
|
|
43
|
+
if (requestedView(controlUrl) === 'presenter' && !controlUrl.searchParams.has('session')) {
|
|
44
|
+
// randomUUID only exists on https or localhost, not on an iPad at http://192.168…
|
|
45
|
+
controlUrl.searchParams.set('session', crypto.randomUUID?.() ?? Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join(''))
|
|
46
|
+
history.replaceState(null, '', controlUrl)
|
|
47
|
+
}
|
|
48
|
+
const DECK_CHANNEL = `deck-control:${controlUrl.pathname}:${controlUrl.searchParams.get('session') ?? 'default'}`
|
|
49
|
+
|
|
50
|
+
function withConfigOverrides(deckConfig) {
|
|
51
|
+
const url = new URL(window.location.href)
|
|
52
|
+
const theme = url.searchParams.get('theme')
|
|
53
|
+
const palette = url.searchParams.get('palette')
|
|
54
|
+
const appearance = url.searchParams.get('appearance')
|
|
55
|
+
// The presenter view and previews pass their choices in the address. An
|
|
56
|
+
// empty ?palette= or ?appearance= means the theme's default, whatever the
|
|
57
|
+
// deck sets; a missing one keeps the deck's.
|
|
58
|
+
return {
|
|
59
|
+
...deckConfig,
|
|
60
|
+
// A file sent to readers carries only the deck's own theme and palette;
|
|
61
|
+
// others named in the address are left out rather than failing.
|
|
62
|
+
...(theme && THEME_METAS[theme] ? { theme } : {}),
|
|
63
|
+
...(palette !== null && (!palette || PALETTES[palette]) ? { palette: palette || undefined } : {}),
|
|
64
|
+
...(appearance !== null ? { appearance: appearance || undefined } : {}),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The presenter's screen tells phones on the deck's answer link which
|
|
69
|
+
// activity is on the current slide. Decks without activities never call the
|
|
70
|
+
// server.
|
|
71
|
+
// The deck's text: the file's, or on the dev server with ?sample=1 the sample
|
|
72
|
+
// deck, which the design page's pictures show.
|
|
73
|
+
let slidesContent = deckFileContent
|
|
74
|
+
const hasActivities = () => roomsIn(slidesContent).length > 0
|
|
75
|
+
// What the phones show for the activity on a slide: its component's `phone`
|
|
76
|
+
// description, from the tag's attributes and the slide's heading.
|
|
77
|
+
function activityOn(deck, slide) {
|
|
78
|
+
const room = roomsOnSlide(slide)[0]
|
|
79
|
+
if (!room) return { room: null, activity: null }
|
|
80
|
+
const tag = findRoomTag(slide.content ?? '', room) ?? Object.values(slide.regions ?? {}).map(region => findRoomTag(region.content ?? '', room)).find(Boolean)
|
|
81
|
+
const element = tag && new DOMParser().parseFromString(tag, 'text/html').body.firstElementChild
|
|
82
|
+
const component = element && registry[element.localName]
|
|
83
|
+
if (typeof component?.phone !== 'function') return { room, activity: null }
|
|
84
|
+
const props = Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]))
|
|
85
|
+
try { return { room, activity: component.phone(props, { slideTitle: slideTitleFor(deck, room) }) } } catch (error) { console.warn(error); return { room, activity: null } }
|
|
86
|
+
}
|
|
87
|
+
function announceSlide(deck, index, { initial = false } = {}) {
|
|
88
|
+
if (hasActivities()) announce({ ...activityOn(deck, deck.slides[index]), title: deck.deckConfig?.meta?.title ?? '', initial })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Whether this presenter screen moves the phones along, shown only for decks
|
|
92
|
+
// with activities: a failure used to be silent.
|
|
93
|
+
const STEERING = {
|
|
94
|
+
'no-code': 'Phones do not follow: this browser has no server key. Open the presenter view once with ?serverkey=… (the launch page has a link).',
|
|
95
|
+
'wrong-code': "Phones do not follow: the server does not accept this browser's presenter code.",
|
|
96
|
+
unreachable: 'Phones do not follow: the server does not answer.',
|
|
97
|
+
'other-screen': 'Phones follow another screen, where this presentation was moved on more recently. Change the slide here to take them back.',
|
|
98
|
+
}
|
|
99
|
+
function SteeringNote() {
|
|
100
|
+
const steering = useSteering()
|
|
101
|
+
if (!hasActivities() || !steering) return null
|
|
102
|
+
return <div role="status" style={{ flexShrink: 0, padding: '6px 10px', borderRadius: '5px', fontSize: '12px', lineHeight: 1.4, border: `1px solid ${steering.ok ? '#2c4a35' : '#5c2b28'}`, color: steering.ok ? '#81c995' : '#f28b82' }}>
|
|
103
|
+
{steering.ok ? 'Phones follow this presentation.' : STEERING[steering.reason]}
|
|
104
|
+
</div>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function injectSpeakerNotes(slides) {
|
|
108
|
+
let tag = document.getElementById('speaker-notes')
|
|
109
|
+
if (!tag) {
|
|
110
|
+
tag = document.createElement('script')
|
|
111
|
+
tag.id = 'speaker-notes'
|
|
112
|
+
tag.type = 'application/json'
|
|
113
|
+
document.body.appendChild(tag)
|
|
114
|
+
}
|
|
115
|
+
tag.textContent = JSON.stringify(slides.map(s => s.meta?.notes ?? ''))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// URL for the presenter's own iframe and preview pane (uses postMessage)
|
|
119
|
+
function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false } = {}) {
|
|
120
|
+
const url = new URL(window.location.href)
|
|
121
|
+
url.searchParams.delete('v')
|
|
122
|
+
url.searchParams.set('view', 'deck')
|
|
123
|
+
url.searchParams.set('embedded', '1')
|
|
124
|
+
if (draw) url.searchParams.set('draw', '1')
|
|
125
|
+
if (theme) url.searchParams.set('theme', theme)
|
|
126
|
+
else url.searchParams.delete('theme')
|
|
127
|
+
setLookParams(url, palette, appearance)
|
|
128
|
+
if (slideIndex != null) url.hash = String(slideIndex + 1)
|
|
129
|
+
return url.toString()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The palette and light or dark chosen in the presenter view, for its frames
|
|
133
|
+
// and the audience window.
|
|
134
|
+
function setLookParams(url, palette, appearance) {
|
|
135
|
+
// Always set: empty means the theme's default (see withConfigOverrides).
|
|
136
|
+
url.searchParams.set('palette', palette ?? '')
|
|
137
|
+
url.searchParams.set('appearance', appearance ?? '')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// URL for the audience window — view=audience makes it listen on BroadcastChannel
|
|
141
|
+
function buildAudienceUrl(theme, palette, appearance, slideIndex = null) {
|
|
142
|
+
const url = new URL(window.location.href)
|
|
143
|
+
url.searchParams.delete('embedded')
|
|
144
|
+
url.searchParams.delete('v')
|
|
145
|
+
url.searchParams.set('view', 'audience')
|
|
146
|
+
if (theme) url.searchParams.set('theme', theme)
|
|
147
|
+
else url.searchParams.delete('theme')
|
|
148
|
+
setLookParams(url, palette, appearance)
|
|
149
|
+
if (slideIndex != null) url.hash = String(slideIndex + 1)
|
|
150
|
+
return url.toString()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sendTo(win, command, value) {
|
|
154
|
+
if (!win || win.closed) return
|
|
155
|
+
win.postMessage({ deckControl: { command, value } }, '*')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Who is connected, as the presenter's main frame hears it from the server.
|
|
159
|
+
// Kept from the first message on, since a presenter view opened in a
|
|
160
|
+
// background tab runs its effects only once it is shown.
|
|
161
|
+
let lastPresence = null
|
|
162
|
+
const presenceListeners = new Set()
|
|
163
|
+
function followPresence() {
|
|
164
|
+
window.addEventListener('message', ({ data, origin }) => {
|
|
165
|
+
if (origin !== window.location.origin || !data?.presence) return
|
|
166
|
+
lastPresence = data.presence
|
|
167
|
+
for (const listener of presenceListeners) listener(lastPresence)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
function usePresence() {
|
|
171
|
+
const [presence, setPresence] = useState(lastPresence)
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
presenceListeners.add(setPresence)
|
|
174
|
+
setPresence(lastPresence)
|
|
175
|
+
return () => presenceListeners.delete(setPresence)
|
|
176
|
+
}, [])
|
|
177
|
+
return presence
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function PresenterView({ deckConfig, slides }) {
|
|
181
|
+
const [index, setIndex] = useState(0)
|
|
182
|
+
const [theme, setTheme] = useState(deckConfig.theme ?? 'neue')
|
|
183
|
+
// '' means the theme's own default, for both.
|
|
184
|
+
const [palette, setPalette] = useState(deckConfig.palette ?? '')
|
|
185
|
+
const [appearance, setAppearance] = useState(deckConfig.appearance ?? '')
|
|
186
|
+
const [audienceConnected, setAudienceConnected] = useState(false)
|
|
187
|
+
const [noteSize, setNoteSize] = useState(13)
|
|
188
|
+
const [inking, setInking] = useState(false)
|
|
189
|
+
const presence = usePresence()
|
|
190
|
+
// Drawing in the slide's frame: this page around it must not pan or bounce either.
|
|
191
|
+
useEffect(() => { document.documentElement.classList.toggle('is-inking', inking) }, [inking])
|
|
192
|
+
// The slide zooms itself (two fingers in its frame); Safari's page zoom
|
|
193
|
+
// would enlarge the bars around it as well.
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const stop = event => event.preventDefault()
|
|
196
|
+
document.addEventListener('gesturestart', stop, { passive: false })
|
|
197
|
+
return () => document.removeEventListener('gesturestart', stop)
|
|
198
|
+
}, [])
|
|
199
|
+
// 'slide': the slide fills the screen (an iPad); the notes open as a drawer.
|
|
200
|
+
const [layout, setLayout] = useState(() => {
|
|
201
|
+
try { const saved = localStorage.getItem('mdeck-presenter-layout'); if (saved) return saved } catch {}
|
|
202
|
+
return matchMedia('(pointer: coarse)').matches ? 'slide' : 'speaker'
|
|
203
|
+
})
|
|
204
|
+
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
205
|
+
const slideLayout = layout === 'slide'
|
|
206
|
+
function chooseLayout(next) {
|
|
207
|
+
setLayout(next)
|
|
208
|
+
setDrawerOpen(false)
|
|
209
|
+
try { localStorage.setItem('mdeck-presenter-layout', next) } catch {}
|
|
210
|
+
}
|
|
211
|
+
const fullscreen = window.mdeckFullscreen
|
|
212
|
+
const canFullscreen = !!fullscreen?.available()
|
|
213
|
+
// The button enters and leaves full screen, and shows which it will do.
|
|
214
|
+
const [isFullscreen, setIsFullscreen] = useState(() => !!fullscreen?.element())
|
|
215
|
+
useEffect(() => fullscreen?.onChange?.(setIsFullscreen), [])
|
|
216
|
+
const fullscreenLabel = isFullscreen ? 'Exit full screen (F)' : 'Full screen (F)'
|
|
217
|
+
const fullscreenIcon = isFullscreen ? 'fullscreen-exit' : 'fullscreen'
|
|
218
|
+
|
|
219
|
+
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
220
|
+
// The theme row shows unless the presenter folded it, like the ink toolbar,
|
|
221
|
+
// into one round button.
|
|
222
|
+
const [lookFolded, setLookFolded] = useState(() => { try { return localStorage.getItem('mdeck-presenter-look') === 'folded' } catch { return false } })
|
|
223
|
+
function foldLook(folded) {
|
|
224
|
+
setLookFolded(folded)
|
|
225
|
+
setPaletteOpen(false)
|
|
226
|
+
try { localStorage.setItem('mdeck-presenter-look', folded ? 'folded' : 'open') } catch {}
|
|
227
|
+
}
|
|
228
|
+
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
|
229
|
+
const [elapsed, setElapsed] = useState(0)
|
|
230
|
+
const [timerRunning, setTimerRunning] = useState(false)
|
|
231
|
+
const timerRef = useRef(null)
|
|
232
|
+
|
|
233
|
+
const iframeRef = useRef(null)
|
|
234
|
+
const previewRef = useRef(null)
|
|
235
|
+
const audienceRef = useRef(null)
|
|
236
|
+
const bcRef = useRef(null)
|
|
237
|
+
// Positions sent to and taken from audience windows are ordered (syncOrder.js).
|
|
238
|
+
const orderRef = useRef(null)
|
|
239
|
+
orderRef.current ??= createOrder()
|
|
240
|
+
const indexRef = useRef(0)
|
|
241
|
+
const stateRef = useRef({ index: 0, step: -1 })
|
|
242
|
+
const paletteDropRef = useRef(null)
|
|
243
|
+
indexRef.current = index
|
|
244
|
+
// Phones see the look of the slide this view shows, read from its frame.
|
|
245
|
+
useEffect(() => { setLookSource(() => iframeRef.current?.contentDocument) }, [])
|
|
246
|
+
const announcedOnce = useRef(false)
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
announceSlide({ slides, deckConfig }, index, { initial: !announcedOnce.current })
|
|
249
|
+
announcedOnce.current = true
|
|
250
|
+
}, [index])
|
|
251
|
+
|
|
252
|
+
const themeMeta = THEME_METAS[theme]
|
|
253
|
+
// The palettes this theme offers, and what the deck shows right now.
|
|
254
|
+
const offered = palettesFor(themeMeta, PALETTES)
|
|
255
|
+
const shown = resolvePalette({ theme: themeMeta, palettes: PALETTES, palette, appearance })
|
|
256
|
+
// A theme that does not offer the chosen palette falls back to its own.
|
|
257
|
+
useEffect(() => { if (palette && !offered.some(p => p.id === palette)) setPalette('') }, [theme])
|
|
258
|
+
const notes = useMemo(() => slides.map(s => s.meta?.notes ?? ''), [slides])
|
|
259
|
+
// Preserve current slide when theme/palette causes an iframe reload
|
|
260
|
+
const iframeSrc = useMemo(() => buildChildUrl(theme, palette, appearance, indexRef.current, { draw: true }), [theme, palette, appearance])
|
|
261
|
+
// previewSrc only recomputes on a change of look; slide changes use postMessage
|
|
262
|
+
const previewSrc = useMemo(
|
|
263
|
+
() => buildChildUrl(theme, palette, appearance, indexRef.current + 1),
|
|
264
|
+
[theme, palette, appearance]
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
// BroadcastChannel for audience sync — more reliable than cross-window postMessage
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
bcRef.current = new BroadcastChannel(DECK_CHANNEL)
|
|
270
|
+
bcRef.current.onmessage = ({ data }) => {
|
|
271
|
+
if (data?.audienceReady) bcRef.current?.postMessage({ deckControl: { command: 'setState', value: stateRef.current, order: orderRef.current.current() } })
|
|
272
|
+
// An audience window moved: follow it, unless this view has moved on since.
|
|
273
|
+
if (data?.deckStateChanged && orderRef.current.accept(data.order)) sendTo(iframeRef.current?.contentWindow, 'setState', data.deckStateChanged)
|
|
274
|
+
}
|
|
275
|
+
return () => bcRef.current?.close()
|
|
276
|
+
}, [])
|
|
277
|
+
|
|
278
|
+
// When the presenter iframe navigates: update displayed index, sync audience + preview.
|
|
279
|
+
// Skip audience/preview sync on 'init' (iframe load/reload) — the audience already
|
|
280
|
+
// opened at the correct slide, and previewSrc encodes the right hash on reload.
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
function onMessage({ data, source }) {
|
|
283
|
+
if (source === previewRef.current?.contentWindow) {
|
|
284
|
+
if (data?.deckStateChanged && data.reason === 'init') sendTo(source, 'goTo', indexRef.current + 1)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (source !== iframeRef.current?.contentWindow) return
|
|
288
|
+
if (typeof data?.inkMode === 'boolean') { setInking(data.inkMode); return }
|
|
289
|
+
if (!data?.deckStateChanged) return
|
|
290
|
+
const state = data.deckStateChanged
|
|
291
|
+
const i = state.index
|
|
292
|
+
// Theme reloads must not reset the current reveal position.
|
|
293
|
+
if (data.reason === 'init' && stateRef.current.index === i) {
|
|
294
|
+
sendTo(iframeRef.current?.contentWindow, 'setState', stateRef.current)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
stateRef.current = state
|
|
298
|
+
setIndex(i)
|
|
299
|
+
if (data.reason !== 'sync') {
|
|
300
|
+
bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
|
|
301
|
+
}
|
|
302
|
+
sendTo(previewRef.current?.contentWindow, 'goTo', i + 1)
|
|
303
|
+
}
|
|
304
|
+
window.addEventListener('message', onMessage)
|
|
305
|
+
// The frame may have started drawing before this listener was there.
|
|
306
|
+
try { if (iframeRef.current?.contentWindow?.document.querySelector('deck-stage')?.inking) setInking(true) } catch {}
|
|
307
|
+
return () => window.removeEventListener('message', onMessage)
|
|
308
|
+
}, [])
|
|
309
|
+
|
|
310
|
+
// Keyboard nav in the presenter sidebar → drive the iframe only;
|
|
311
|
+
// audience follows automatically via the slideIndexChanged → BroadcastChannel path
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
function onKey(e) {
|
|
314
|
+
if (e.target?.matches?.('input, select, textarea')) return
|
|
315
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
316
|
+
const cmd =
|
|
317
|
+
(e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') ? 'next' :
|
|
318
|
+
(e.key === 'ArrowLeft' || e.key === 'PageUp') ? 'prev' :
|
|
319
|
+
(e.key === 'Home' || e.key === 'r' || e.key === 'R') ? 'reset' :
|
|
320
|
+
(e.key === 'd' || e.key === 'D') ? 'ink' : null
|
|
321
|
+
if ((e.key === 'f' || e.key === 'F') && fullscreen?.available()) { e.preventDefault(); fullscreen.toggle(); return }
|
|
322
|
+
if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setDrawerOpen(open => !open); return }
|
|
323
|
+
if (!cmd) return
|
|
324
|
+
e.preventDefault()
|
|
325
|
+
sendTo(iframeRef.current?.contentWindow, cmd, cmd === 'ink' ? 'toggle' : undefined)
|
|
326
|
+
}
|
|
327
|
+
window.addEventListener('keydown', onKey)
|
|
328
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
329
|
+
}, [])
|
|
330
|
+
|
|
331
|
+
// Escape to close theme modal
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!themeModalOpen) return
|
|
334
|
+
function handler(e) {
|
|
335
|
+
if (e.key === 'Escape') setThemeModalOpen(false)
|
|
336
|
+
}
|
|
337
|
+
window.addEventListener('keydown', handler)
|
|
338
|
+
return () => window.removeEventListener('keydown', handler)
|
|
339
|
+
}, [themeModalOpen])
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (timerRunning) {
|
|
343
|
+
timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000)
|
|
344
|
+
} else {
|
|
345
|
+
clearInterval(timerRef.current)
|
|
346
|
+
}
|
|
347
|
+
return () => clearInterval(timerRef.current)
|
|
348
|
+
}, [timerRunning])
|
|
349
|
+
|
|
350
|
+
// Close palette dropdown on outside click
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!paletteOpen) return
|
|
353
|
+
function handler(e) {
|
|
354
|
+
if (!paletteDropRef.current?.contains(e.target)) setPaletteOpen(false)
|
|
355
|
+
}
|
|
356
|
+
document.addEventListener('mousedown', handler)
|
|
357
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
358
|
+
}, [paletteOpen])
|
|
359
|
+
|
|
360
|
+
// When theme/palette changes, tell the audience to hot-swap its theme without
|
|
361
|
+
// a reload. Phones pick it up with the next announcement.
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
bcRef.current?.postMessage({ deckControl: { command: 'setTheme', theme, palette, appearance } })
|
|
364
|
+
}, [theme, palette, appearance])
|
|
365
|
+
|
|
366
|
+
// Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
|
|
367
|
+
function navCommand(cmd) {
|
|
368
|
+
sendTo(iframeRef.current?.contentWindow, cmd)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function openAudienceWindow() {
|
|
372
|
+
const aw = window.open(
|
|
373
|
+
buildAudienceUrl(theme, palette, appearance, indexRef.current),
|
|
374
|
+
'deck-audience-view'
|
|
375
|
+
)
|
|
376
|
+
if (!aw) return
|
|
377
|
+
audienceRef.current = aw
|
|
378
|
+
setAudienceConnected(true)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const note = notes[index] || ''
|
|
382
|
+
const hasNext = index + 1 < slides.length
|
|
383
|
+
|
|
384
|
+
const clock = `${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`
|
|
385
|
+
const pill = { ...S.btn, minWidth: '40px', height: '40px', padding: '0 10px', fontSize: '15px' }
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div class={`presenter presenter--${layout}`} style={{ display: 'grid', gridTemplateColumns: slideLayout ? '1fr' : '2fr 1fr', height: '100dvh', background: '#111', overflow: 'hidden', touchAction: 'pan-x pan-y' }}>
|
|
389
|
+
<iframe
|
|
390
|
+
ref={iframeRef}
|
|
391
|
+
title="Presenter deck"
|
|
392
|
+
src={iframeSrc}
|
|
393
|
+
style={{ width: '100%', height: '100%', border: '0' }}
|
|
394
|
+
/>
|
|
395
|
+
{slideLayout && (
|
|
396
|
+
<div class="presenter-pill" role="toolbar" aria-label="Presenter" style={{ position: 'fixed', top: 'max(10px, env(safe-area-inset-top))', right: 'max(10px, env(safe-area-inset-right))', zIndex: 20, display: 'flex', gap: '4px', alignItems: 'center', padding: '4px', borderRadius: '12px', background: 'rgba(17,17,17,0.88)', border: '1px solid #2a2a2a', color: '#ccc', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', fontVariantNumeric: 'tabular-nums' }}>
|
|
397
|
+
<span style={{ padding: '0 8px', color: '#888' }}>{index + 1}/{slides.length}</span>
|
|
398
|
+
<ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888', padding: '0 6px' }} />
|
|
399
|
+
<button style={{ ...pill, color: timerRunning ? '#f0f0f0' : '#777' }} title="Start or pause the timer" onClick={() => setTimerRunning(r => !r)}>{clock}</button>
|
|
400
|
+
<button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
|
|
401
|
+
<button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
|
|
402
|
+
<button class="presenter-draw" title="Draw on the slide (D)" aria-pressed={inking} style={{ ...pill, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }} onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}><PresenterIcon name="pen" /></button>
|
|
403
|
+
<button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
|
|
404
|
+
{canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
|
|
405
|
+
<button style={pill} title="Speaker layout: slide, notes and next slide side by side" onClick={() => chooseLayout('speaker')}><PresenterIcon name="speaker" /></button>
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
<aside class="presenter-aside" style={{
|
|
409
|
+
display: slideLayout && !drawerOpen ? 'none' : 'flex',
|
|
410
|
+
flexDirection: 'column',
|
|
411
|
+
gap: '14px',
|
|
412
|
+
padding: '16px',
|
|
413
|
+
borderLeft: '1px solid #2a2a2a',
|
|
414
|
+
background: '#111',
|
|
415
|
+
color: '#ccc',
|
|
416
|
+
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
417
|
+
fontSize: '13px',
|
|
418
|
+
overflow: 'hidden',
|
|
419
|
+
...(slideLayout ? { position: 'fixed', top: 0, right: 0, bottom: 0, width: 'min(380px, 92vw)', zIndex: 19, paddingTop: '64px', boxShadow: '-12px 0 32px rgba(0,0,0,0.5)' } : {}),
|
|
420
|
+
}}>
|
|
421
|
+
|
|
422
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
|
423
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
424
|
+
<span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '14px' }}>Speaker View</span>
|
|
425
|
+
<ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888' }} />
|
|
426
|
+
</span>
|
|
427
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
428
|
+
{!slideLayout && canFullscreen && <button class="presenter-fullscreen" style={{ ...S.btn, padding: '3px 8px' }} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
|
|
429
|
+
{!slideLayout && <button class="presenter-layout" style={{ ...S.btn, padding: '3px 8px' }} title="Slide only, notes in a drawer (for an iPad)" onClick={() => chooseLayout('slide')}><PresenterIcon name="tablet" /></button>}
|
|
430
|
+
<span style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }}>
|
|
431
|
+
{String(index + 1).padStart(2, '0')} / {String(slides.length).padStart(2, '0')}
|
|
432
|
+
</span>
|
|
433
|
+
</span>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<SteeringNote />
|
|
437
|
+
|
|
438
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
|
439
|
+
<span style={{ fontVariantNumeric: 'tabular-nums', fontSize: '22px', letterSpacing: '0.05em', color: timerRunning ? '#f0f0f0' : '#555', fontWeight: 300 }}>
|
|
440
|
+
{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}
|
|
441
|
+
</span>
|
|
442
|
+
<div style={{ display: 'flex', gap: '4px' }}>
|
|
443
|
+
<button style={S.btn} title={timerRunning ? 'Pause the timer' : 'Start the timer'} aria-label={timerRunning ? 'Pause the timer' : 'Start the timer'} onClick={() => setTimerRunning(r => !r)}>
|
|
444
|
+
<PresenterIcon name={timerRunning ? 'pause' : 'play'} size={16} />
|
|
445
|
+
</button>
|
|
446
|
+
<button style={S.btn} title="Reset the timer" aria-label="Reset the timer" onClick={() => { setTimerRunning(false); setElapsed(0) }}><PresenterIcon name="reset" size={16} /></button>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
{hasNext && (
|
|
451
|
+
<div style={{ flexShrink: 0 }}>
|
|
452
|
+
<span style={S.label}>Next slide</span>
|
|
453
|
+
<div style={{ aspectRatio: '16/9', borderRadius: '4px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
|
|
454
|
+
<iframe
|
|
455
|
+
ref={previewRef}
|
|
456
|
+
title="Next slide preview"
|
|
457
|
+
src={previewSrc}
|
|
458
|
+
scrolling="no"
|
|
459
|
+
style={{ width: '100%', height: '100%', border: '0', pointerEvents: 'none' }}
|
|
460
|
+
/>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
|
|
465
|
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
466
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
|
|
467
|
+
<span style={{ ...S.label, marginBottom: 0 }}>Notes</span>
|
|
468
|
+
<input
|
|
469
|
+
type="range" min="11" max="22" step="1" value={noteSize}
|
|
470
|
+
onInput={e => setNoteSize(Number(e.currentTarget.value))}
|
|
471
|
+
style={{ width: '80px', accentColor: '#444', cursor: 'pointer', opacity: 0.6 }}
|
|
472
|
+
/>
|
|
473
|
+
</div>
|
|
474
|
+
<div class="notes-md" style={{
|
|
475
|
+
flex: 1,
|
|
476
|
+
margin: 0,
|
|
477
|
+
padding: '10px 12px',
|
|
478
|
+
lineHeight: 1.6,
|
|
479
|
+
background: '#0a0a0a',
|
|
480
|
+
border: '1px solid #2a2a2a',
|
|
481
|
+
borderRadius: '5px',
|
|
482
|
+
fontSize: `${noteSize}px`,
|
|
483
|
+
color: note ? '#c8c8c8' : '#3a3a3a',
|
|
484
|
+
fontFamily: 'inherit',
|
|
485
|
+
overflow: 'auto',
|
|
486
|
+
}}
|
|
487
|
+
dangerouslySetInnerHTML={{
|
|
488
|
+
__html: note
|
|
489
|
+
? marked.parse(note)
|
|
490
|
+
: '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
|
|
491
|
+
}}
|
|
492
|
+
/>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
{!lookFolded && <div class="presenter-look" style={{ flexShrink: 0, display: 'flex', gap: '6px', marginBottom: '8px', alignItems: 'flex-end' }}>
|
|
496
|
+
<label style={{ flex: 1 }}>
|
|
497
|
+
<span style={S.label}>Theme</span>
|
|
498
|
+
<button
|
|
499
|
+
onClick={() => setThemeModalOpen(true)}
|
|
500
|
+
style={{ ...S.select, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '4px', cursor: 'pointer' }}
|
|
501
|
+
>
|
|
502
|
+
<span>{theme}</span>
|
|
503
|
+
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="grid" size={13} /></span>
|
|
504
|
+
</button>
|
|
505
|
+
</label>
|
|
506
|
+
<label style={{ flex: 1 }}>
|
|
507
|
+
<span style={S.label}>Palette</span>
|
|
508
|
+
<div ref={paletteDropRef} style={{ position: 'relative' }}>
|
|
509
|
+
<button
|
|
510
|
+
class="presenter-palette"
|
|
511
|
+
onClick={() => setPaletteOpen(o => !o)}
|
|
512
|
+
style={{ ...S.select, display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', textAlign: 'left' }}
|
|
513
|
+
>
|
|
514
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
|
|
515
|
+
<PaletteSwatches tokens={shown.palette?.[shown.appearance] ?? {}} />
|
|
516
|
+
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="dropdown" size={13} /></span>
|
|
517
|
+
</button>
|
|
518
|
+
{paletteOpen && (
|
|
519
|
+
<div style={{ position: 'absolute', bottom: 'calc(100% + 4px)', left: 0, right: 0, background: '#1a1a1a', border: '1px solid #2e2e2e', borderRadius: '5px', zIndex: 100, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.6)' }}>
|
|
520
|
+
{offered.map(p => (
|
|
521
|
+
<div
|
|
522
|
+
key={p.id}
|
|
523
|
+
class="presenter-palette-option"
|
|
524
|
+
title={p.description}
|
|
525
|
+
onClick={() => { setPalette(p.id === themeMeta?.palette ? '' : p.id); setPaletteOpen(false) }}
|
|
526
|
+
style={{ padding: '6px 8px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '6px', fontSize: '13px', color: shown.palette?.id === p.id ? '#f0f0f0' : '#aaa', background: shown.palette?.id === p.id ? '#2a2a2a' : 'transparent' }}
|
|
527
|
+
>
|
|
528
|
+
<span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
|
|
529
|
+
<PaletteSwatches tokens={p[shown.appearance] ?? {}} />
|
|
530
|
+
</div>
|
|
531
|
+
))}
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
</label>
|
|
536
|
+
<label style={{ flexShrink: 0 }}>
|
|
537
|
+
<span style={S.label}>Light or dark</span>
|
|
538
|
+
<div class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px', height: '28px' }}>
|
|
539
|
+
{['light', 'dark'].map(mode => (
|
|
540
|
+
<button
|
|
541
|
+
key={mode}
|
|
542
|
+
aria-pressed={shown.appearance === mode}
|
|
543
|
+
title={mode === 'light' ? 'Light: for bright rooms' : 'Dark: for dark rooms'}
|
|
544
|
+
aria-label={mode === 'light' ? 'Light' : 'Dark'}
|
|
545
|
+
onClick={() => setAppearance(mode === (themeMeta?.appearance ?? 'light') ? '' : mode)}
|
|
546
|
+
style={{ ...S.btn, padding: '3px 7px', ...(shown.appearance === mode ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
|
|
547
|
+
><PresenterIcon name={mode === 'light' ? 'sun' : 'moon'} size={16} /></button>
|
|
548
|
+
))}
|
|
549
|
+
</div>
|
|
550
|
+
</label>
|
|
551
|
+
<button class="presenter-look-fold" title="Fold theme and palette" aria-label="Fold theme and palette" onClick={() => foldLook(true)}
|
|
552
|
+
style={{ ...S.btn, padding: 0, width: '28px', height: '28px', flexShrink: 0, background: 'none', borderColor: 'transparent', color: '#888' }}
|
|
553
|
+
><PresenterIcon name="fold" size={16} /></button>
|
|
554
|
+
</div>}
|
|
555
|
+
|
|
556
|
+
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
557
|
+
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
558
|
+
<button style={S.btn} onClick={() => navCommand('prev')}><PresenterIcon name="prev" size={15} />Prev</button>
|
|
559
|
+
<button style={S.btn} onClick={() => navCommand('next')}>Next<PresenterIcon name="next" size={15} /></button>
|
|
560
|
+
<button style={S.btn} onClick={() => navCommand('reset')}>Reset</button>
|
|
561
|
+
<button
|
|
562
|
+
class="presenter-draw"
|
|
563
|
+
title="Draw on the slide (D)"
|
|
564
|
+
aria-pressed={inking}
|
|
565
|
+
style={{ ...S.btn, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }}
|
|
566
|
+
onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}
|
|
567
|
+
><PresenterIcon name="pen" size={15} />Draw</button>
|
|
568
|
+
{lookFolded && <button
|
|
569
|
+
class="presenter-look-unfold"
|
|
570
|
+
title={`Theme and palette: ${themeMeta?.title ?? theme}, ${shown.palette?.title ?? ''}`}
|
|
571
|
+
aria-label="Show theme and palette"
|
|
572
|
+
onClick={() => foldLook(false)}
|
|
573
|
+
style={{ ...S.btn, position: 'relative', padding: 0, width: '32px', height: '32px', borderRadius: '50%' }}
|
|
574
|
+
>
|
|
575
|
+
<PresenterIcon name="look" size={16} />
|
|
576
|
+
<span style={{ position: 'absolute', right: '2px', bottom: '2px', width: '9px', height: '9px', borderRadius: '50%', background: shown.palette?.[shown.appearance]?.['--accent'] ?? '#888', boxShadow: '0 0 0 1.5px #222' }} />
|
|
577
|
+
</button>}
|
|
578
|
+
<button
|
|
579
|
+
style={{
|
|
580
|
+
...S.btn,
|
|
581
|
+
marginLeft: 'auto',
|
|
582
|
+
color: audienceConnected ? '#444' : '#bbb',
|
|
583
|
+
}}
|
|
584
|
+
onClick={openAudienceWindow}
|
|
585
|
+
>
|
|
586
|
+
{audienceConnected ? 'Reconnect' : 'Audience'}
|
|
587
|
+
</button>
|
|
588
|
+
</div>
|
|
589
|
+
<div style={{ color: '#444', fontSize: '11px', textAlign: 'center' }}>
|
|
590
|
+
Arrow keys · Space · PgUp/PgDn · R to reset
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
</aside>
|
|
595
|
+
|
|
596
|
+
{themeModalOpen && (
|
|
597
|
+
<div
|
|
598
|
+
style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
599
|
+
onClick={() => setThemeModalOpen(false)}
|
|
600
|
+
>
|
|
601
|
+
<div
|
|
602
|
+
style={{ background: '#161616', border: '1px solid #2a2a2a', borderRadius: '12px', padding: '24px', width: 'min(90vw, 1000px)', maxHeight: '85vh', overflow: 'auto', display: 'flex', flexDirection: 'column', gap: '16px' }}
|
|
603
|
+
onClick={e => e.stopPropagation()}
|
|
604
|
+
>
|
|
605
|
+
<span style={S.label}>Choose Theme</span>
|
|
606
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '12px' }}>
|
|
607
|
+
{THEME_NAMES.map(n => (
|
|
608
|
+
<div
|
|
609
|
+
key={n}
|
|
610
|
+
onClick={() => { setTheme(n); setThemeModalOpen(false) }}
|
|
611
|
+
style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', border: `2px solid ${n === theme ? '#60a5fa' : '#2a2a2a'}`, background: '#111' }}
|
|
612
|
+
>
|
|
613
|
+
<div style={{ aspectRatio: '16/9', overflow: 'hidden' }}>
|
|
614
|
+
<iframe
|
|
615
|
+
src={buildChildUrl(n, palette, appearance, index)}
|
|
616
|
+
scrolling="no"
|
|
617
|
+
style={{ width: '100%', height: '100%', border: 0, pointerEvents: 'none', display: 'block' }}
|
|
618
|
+
/>
|
|
619
|
+
</div>
|
|
620
|
+
<div style={{ padding: '8px 10px', fontSize: '14px', fontFamily: 'inherit', color: n === theme ? '#f0f0f0' : '#aaa', background: n === theme ? '#1c2a3a' : 'transparent' }}>
|
|
621
|
+
{n}
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
))}
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
)}
|
|
629
|
+
</div>
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Renders (or re-renders) the deck stage. Slides are keyed by id so an edit to
|
|
634
|
+
// one slide leaves the others and the stage's position untouched.
|
|
635
|
+
let unfit = null
|
|
636
|
+
let followDeck = null
|
|
637
|
+
function mountDeck({ deck, deckConfig, selection = null, editor = false }) {
|
|
638
|
+
const { slides } = deck
|
|
639
|
+
injectSpeakerNotes(slides)
|
|
640
|
+
const seen = new Map()
|
|
641
|
+
const app = (
|
|
642
|
+
<deck-stage width={deckConfig.width ?? 1920} height={deckConfig.height ?? 1080}>
|
|
643
|
+
{slides.map((slide, i) => {
|
|
644
|
+
const count = (seen.get(slide.id) ?? 0) + 1
|
|
645
|
+
seen.set(slide.id, count)
|
|
646
|
+
const key = count > 1 ? `${slide.id}#${i}` : slide.id
|
|
647
|
+
const props = { id: slide.id, regions: slide.regions, meta: slide.meta, content: slide.content, deckConfig, index: i, total: slides.length }
|
|
648
|
+
if (!editor) return <SlideRenderer key={key} {...props} />
|
|
649
|
+
return <SlideErrorBoundary key={key} id={slide.id} source={deck.source.slice(slide.source.start, slide.source.end)}><SlideRenderer {...props} /></SlideErrorBoundary>
|
|
650
|
+
})}
|
|
651
|
+
</deck-stage>
|
|
652
|
+
)
|
|
653
|
+
render(app, document.body)
|
|
654
|
+
document.querySelector('deck-stage')?.setLabels(stageLabels())
|
|
655
|
+
const stage = document.querySelector('deck-stage')
|
|
656
|
+
if (stage) stage.inkRenderer = strokePath
|
|
657
|
+
followDeck?.()
|
|
658
|
+
followDeck = followActiveRooms(document.querySelector('deck-stage'), slides)
|
|
659
|
+
// Code taller than its place scrolls; too-full slides are reported (fit.js).
|
|
660
|
+
unfit?.()
|
|
661
|
+
unfit = fitDeck(document.querySelector('deck-stage'))
|
|
662
|
+
if (!selection) return
|
|
663
|
+
// The stage re-collects its slides asynchronously after structural changes.
|
|
664
|
+
setTimeout(() => {
|
|
665
|
+
const stage = document.querySelector('deck-stage')
|
|
666
|
+
if (stage && stage.index !== selection.index) stage.setState({ index: selection.index, slideId: selection.slideId, step: -1 })
|
|
667
|
+
}, 0)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function init() {
|
|
671
|
+
// An iPad that opened the launch page's QR code pairs first.
|
|
672
|
+
await claimPairing()
|
|
673
|
+
const url = new URL(window.location.href)
|
|
674
|
+
if (import.meta.env.DEV && url.searchParams.get('sample') === '1') {
|
|
675
|
+
const { sampleDeck, sampleDataUrl } = await import('../editor/sampleDeck.js')
|
|
676
|
+
slidesContent = sampleDeck(sampleDataUrl())
|
|
677
|
+
}
|
|
678
|
+
const parsed = parseSlides(slidesContent)
|
|
679
|
+
const editorMode = url.searchParams.get('editor') === '1'
|
|
680
|
+
// One switch selects the view: deck, reader, presenter or audience.
|
|
681
|
+
const defaultView = typeof __MDECK_DEFAULT_VIEW__ !== 'undefined' ? __MDECK_DEFAULT_VIEW__ : 'deck'
|
|
682
|
+
const view = requestedView(url, defaultView)
|
|
683
|
+
const errors = validateDeck(parsed, { layouts: manifests }).filter(d => d.severity === 'error')
|
|
684
|
+
if (errors.length && !editorMode) {
|
|
685
|
+
document.body.textContent = errors.map(d => `Line ${d.line}: ${d.message}`).join('\n')
|
|
686
|
+
document.body.style.whiteSpace = 'pre-wrap'
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
const deckConfig = withConfigOverrides(parsed.deckConfig)
|
|
690
|
+
setCalloutLabels(deckConfig)
|
|
691
|
+
setDeckLanguage(deckConfig)
|
|
692
|
+
document.documentElement.lang = deckLanguage()
|
|
693
|
+
configureLive(deckConfig)
|
|
694
|
+
const { slides } = parsed
|
|
695
|
+
const presenterMode = view === 'presenter'
|
|
696
|
+
const audienceMode = view === 'audience'
|
|
697
|
+
const embedded = url.searchParams.get('embedded') === '1'
|
|
698
|
+
const readerMode = view === 'reader' && !editorMode && !embedded
|
|
699
|
+
// The deck window and the presenter's main frame draw; the audience window
|
|
700
|
+
// and the next-slide preview show what is drawn.
|
|
701
|
+
const drawHere = !audienceMode && (!embedded || url.searchParams.get('draw') === '1')
|
|
702
|
+
const inkStorageKey = `mdeck-ink:${inkFileName}:${location.pathname}`
|
|
703
|
+
|
|
704
|
+
injectSpeakerNotes(slides)
|
|
705
|
+
|
|
706
|
+
if (presenterMode) {
|
|
707
|
+
actAsPresenter()
|
|
708
|
+
followPresence()
|
|
709
|
+
document.body.style.margin = '0'
|
|
710
|
+
const s = document.createElement('style')
|
|
711
|
+
s.textContent = `
|
|
712
|
+
.notes-md p { margin: 0 0 6px; }
|
|
713
|
+
.notes-md p:last-child { margin-bottom: 0; }
|
|
714
|
+
.notes-md ul, .notes-md ol { margin: 0 0 6px; padding-left: 1.4em; }
|
|
715
|
+
.notes-md li { margin-bottom: 2px; }
|
|
716
|
+
.notes-md strong, .notes-md b { color: #f0f0f0; font-weight: 600; }
|
|
717
|
+
.notes-md em, .notes-md i { font-style: italic; color: #bbb; }
|
|
718
|
+
.notes-md code { font-family: ui-monospace, monospace; font-size: 0.9em; background: #222; padding: 1px 4px; border-radius: 3px; }
|
|
719
|
+
.notes-md a { color: #aaa; }
|
|
720
|
+
`
|
|
721
|
+
document.head.appendChild(s)
|
|
722
|
+
render(<PresenterView deckConfig={deckConfig} slides={slides} />, document.body)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function handleDeckControl(ctrl) {
|
|
727
|
+
if (!ctrl) return
|
|
728
|
+
const stage = document.querySelector('deck-stage')
|
|
729
|
+
if (!stage) return
|
|
730
|
+
if (ctrl.command === 'next') stage.next()
|
|
731
|
+
else if (ctrl.command === 'prev') stage.prev()
|
|
732
|
+
else if (ctrl.command === 'reset') stage.reset()
|
|
733
|
+
else if (ctrl.command === 'goTo' && Number.isInteger(ctrl.value)) stage.goTo(ctrl.value)
|
|
734
|
+
else if (ctrl.command === 'setState') stage.setState(ctrl.value)
|
|
735
|
+
else if (ctrl.command === 'ink' && drawHere) stage.inking = ctrl.value === 'toggle' ? !stage.inking : !!ctrl.value
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Editor preview: the editor pushes whole sources; render best effort and
|
|
739
|
+
// report back. Bad decks never blank the preview here.
|
|
740
|
+
if (editorMode) {
|
|
741
|
+
const post = message => window.parent.postMessage(message, window.location.origin)
|
|
742
|
+
const bridge = createEditorBridge({
|
|
743
|
+
parse: parseSlides, validate: deck => validateDeck(deck, { layouts: manifests }), loadTheme, setExtensionOverrides,
|
|
744
|
+
setCalloutLabels: config => { setCalloutLabels(config); setDeckLanguage(config); document.documentElement.lang = deckLanguage() },
|
|
745
|
+
applyOverrides: withConfigOverrides, mount: context => mountDeck({ ...context, editor: true }), post,
|
|
746
|
+
})
|
|
747
|
+
window.addEventListener('message', event => {
|
|
748
|
+
if (event.source !== window.parent || event.origin !== window.location.origin) return
|
|
749
|
+
if (!bridge.handleMessage(event.data)) handleDeckControl(event.data?.deckControl)
|
|
750
|
+
})
|
|
751
|
+
await bridge.render(slidesContent)
|
|
752
|
+
post({ deckEditorReady: true })
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (readerMode) {
|
|
757
|
+
document.body.style.margin = '0'
|
|
758
|
+
await loadTheme(deckConfig)
|
|
759
|
+
render(<ReaderView deck={parsed} deckConfig={deckConfig} />, document.body)
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
await loadTheme(deckConfig)
|
|
764
|
+
mountDeck({ deck: parsed, deckConfig })
|
|
765
|
+
if (!embedded && !audienceMode) {
|
|
766
|
+
// A quiet way into the reader view for anyone who opened the file directly.
|
|
767
|
+
const entry = document.createElement('a')
|
|
768
|
+
entry.className = 'reader-entry'
|
|
769
|
+
entry.href = '?view=reader'
|
|
770
|
+
entry.textContent = t('deck.overview')
|
|
771
|
+
document.body.appendChild(entry)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Drawing (D) in the full-screen deck and the presenter's main frame.
|
|
775
|
+
const inkStage = document.querySelector('deck-stage')
|
|
776
|
+
// Main slide frames and audience windows both send and follow navigation.
|
|
777
|
+
// Share their position connection with ink; previews stay on local channels.
|
|
778
|
+
if (inkStage && (drawHere || audienceMode)) {
|
|
779
|
+
followStageRoom(inkStage)
|
|
780
|
+
announceStagePosition(inkStage)
|
|
781
|
+
}
|
|
782
|
+
if (inkStage && drawHere) {
|
|
783
|
+
attachInk(inkStage, {
|
|
784
|
+
storageKey: inkStorageKey,
|
|
785
|
+
// The presenter's main frame tells the presenter view who is connected.
|
|
786
|
+
transports: [roomTransport({ receive: true, view: embedded ? 'presenter' : 'deck', onPresence: embedded ? presence => window.parent.postMessage({ presence }, window.location.origin) : null })],
|
|
787
|
+
onMode: inking => { if (embedded) window.parent.postMessage({ inkMode: inking }, window.location.origin) },
|
|
788
|
+
})
|
|
789
|
+
// Drawing turns itself on, with the laser and the toolbar folded into
|
|
790
|
+
// its corner button, where it is wanted: in the presenter view on a
|
|
791
|
+
// touch screen (an iPad), and in a deck window as soon as a pen touches
|
|
792
|
+
// it. A phone or tablet only browsing the deck keeps its taps. D or the
|
|
793
|
+
// close button turns it off.
|
|
794
|
+
const startDrawing = () => { inkStage._inkCollapsed = true; inkStage.inking = true }
|
|
795
|
+
if (embedded && matchMedia('(pointer: coarse)').matches) startDrawing()
|
|
796
|
+
else if (!embedded) {
|
|
797
|
+
const onPen = event => {
|
|
798
|
+
if (event.pointerType !== 'pen') return
|
|
799
|
+
window.removeEventListener('pointerdown', onPen, true)
|
|
800
|
+
if (!inkStage.inking) startDrawing()
|
|
801
|
+
}
|
|
802
|
+
window.addEventListener('pointerdown', onPen, true)
|
|
803
|
+
}
|
|
804
|
+
} else if (inkStage && audienceMode) {
|
|
805
|
+
// The audience window shows the presenter's zoom and a poll's answer, without their controls.
|
|
806
|
+
inkStage.setAttribute('data-follower', '')
|
|
807
|
+
watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'audience' })] })
|
|
808
|
+
} else if (inkStage) watchInk(inkStage, { storageKey: inkStorageKey })
|
|
809
|
+
|
|
810
|
+
// A full deck or audience window is a presenter's screen; previews are embedded.
|
|
811
|
+
if (!embedded) {
|
|
812
|
+
const stage = document.querySelector('deck-stage')
|
|
813
|
+
stage?.addEventListener('slidechange', event => announceSlide(parsed, event.detail.index, { initial: event.detail.reason === 'init' }))
|
|
814
|
+
if (stage) announceSlide(parsed, stage.index, { initial: true })
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Embedded iframes (presenter view + preview pane) receive commands via postMessage
|
|
818
|
+
window.addEventListener('message', event => {
|
|
819
|
+
if (event.source === window.parent && event.origin === window.location.origin) handleDeckControl(event.data?.deckControl)
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
// Audience window receives commands via BroadcastChannel — reliable same-origin sync
|
|
823
|
+
// that doesn't depend on keeping a live cross-window reference
|
|
824
|
+
if (audienceMode) {
|
|
825
|
+
const bc = new BroadcastChannel(DECK_CHANNEL)
|
|
826
|
+
const order = createOrder()
|
|
827
|
+
bc.onmessage = ({ data }) => {
|
|
828
|
+
const ctrl = data?.deckControl
|
|
829
|
+
if (!ctrl) return
|
|
830
|
+
// A position older than this window's own last change is ignored.
|
|
831
|
+
if (ctrl.command === 'setState' && !order.accept(ctrl.order)) return
|
|
832
|
+
if (ctrl.command === 'setTheme') {
|
|
833
|
+
loadTheme({ theme: ctrl.theme, palette: ctrl.palette || undefined, appearance: ctrl.appearance || undefined })
|
|
834
|
+
} else {
|
|
835
|
+
handleDeckControl(ctrl)
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
document.querySelector('deck-stage')?.addEventListener('slidechange', event => {
|
|
839
|
+
if (event.detail.reason === 'init') bc.postMessage({ audienceReady: true })
|
|
840
|
+
})
|
|
841
|
+
document.querySelector('deck-stage')?.addEventListener('statechange', event => {
|
|
842
|
+
if (!['init', 'sync'].includes(event.detail.reason)) bc.postMessage({ deckStateChanged: event.detail, order: order.stamp() })
|
|
843
|
+
})
|
|
844
|
+
bc.postMessage({ audienceReady: true })
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const urlProblem = validatePageUrl(new URL(location.href))
|
|
849
|
+
if (urlProblem) {
|
|
850
|
+
const message = document.createElement('p')
|
|
851
|
+
message.textContent = urlProblem.message
|
|
852
|
+
const panel = document.createElement('main')
|
|
853
|
+
panel.style.cssText = 'font:16px/1.5 system-ui;max-width:48rem;margin:3rem auto;padding:2rem'
|
|
854
|
+
panel.setAttribute('role', 'alert')
|
|
855
|
+
panel.append(message)
|
|
856
|
+
if (urlProblem.corrected) {
|
|
857
|
+
const link = document.createElement('a')
|
|
858
|
+
link.href = urlProblem.corrected
|
|
859
|
+
link.textContent = 'Open updated address'
|
|
860
|
+
panel.append(link)
|
|
861
|
+
}
|
|
862
|
+
document.body.replaceChildren(panel)
|
|
863
|
+
} else init()
|