@liebstoeckel/engine 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +82 -0
- package/package.json +70 -0
- package/src/CaptureView.tsx +96 -0
- package/src/CodeMagic.tsx +76 -0
- package/src/Deck.tsx +286 -0
- package/src/DeckChrome.tsx +240 -0
- package/src/HelpOverlay.tsx +156 -0
- package/src/MobileHint.tsx +71 -0
- package/src/PersistentLayer.tsx +168 -0
- package/src/Present.tsx +113 -0
- package/src/PresenterView.tsx +454 -0
- package/src/PrintView.tsx +151 -0
- package/src/QrOverlay.tsx +133 -0
- package/src/Stage.tsx +82 -0
- package/src/Thumb.tsx +36 -0
- package/src/build/buildDeck.ts +321 -0
- package/src/build/capture-protocol.ts +55 -0
- package/src/build/licenses.ts +336 -0
- package/src/build/mdx-plugin.ts +30 -0
- package/src/build/source-attr.ts +4 -0
- package/src/build/source-package.ts +210 -0
- package/src/build/thumbnails.ts +49 -0
- package/src/build/visx-esm-plugin.ts +42 -0
- package/src/code/diff.ts +61 -0
- package/src/code/macro.ts +24 -0
- package/src/code/tokenize.ts +72 -0
- package/src/code/types.ts +24 -0
- package/src/delivery.ts +32 -0
- package/src/index.ts +55 -0
- package/src/live/Plugin.tsx +160 -0
- package/src/live/PluginBoundary.tsx +34 -0
- package/src/live/breakout.tsx +235 -0
- package/src/live/connect.ts +149 -0
- package/src/live/deckIndex.ts +77 -0
- package/src/live/detect.ts +17 -0
- package/src/live/globalChrome.tsx +185 -0
- package/src/live/globals.ts +15 -0
- package/src/live/index.ts +7 -0
- package/src/live/participant.ts +41 -0
- package/src/live/presenterPanel.tsx +281 -0
- package/src/live/ui.ts +8 -0
- package/src/mobile.ts +59 -0
- package/src/nav.ts +149 -0
- package/src/slides.ts +19 -0
- package/src/source.ts +9 -0
- package/src/steps.tsx +117 -0
- package/src/thumbnails.ts +31 -0
- package/src/transitions.ts +88 -0
- package/src/useCoarsePointer.ts +17 -0
- package/src/useDeckSync.ts +85 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useMemo, type CSSProperties } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
3
|
+
import { keySteps } from "./code/diff";
|
|
4
|
+
import type { TokenizedStep } from "./code/types";
|
|
5
|
+
import { useRevealState } from "./steps";
|
|
6
|
+
|
|
7
|
+
// Shiki FontStyle bitfield → CSS.
|
|
8
|
+
function styleOf(color?: string, fontStyle?: number): CSSProperties {
|
|
9
|
+
return {
|
|
10
|
+
color,
|
|
11
|
+
fontStyle: fontStyle && fontStyle & 1 ? "italic" : undefined,
|
|
12
|
+
fontWeight: fontStyle && fontStyle & 2 ? 700 : undefined,
|
|
13
|
+
textDecoration: fontStyle && fontStyle & 4 ? "underline" : undefined,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Animated code that morphs between states. Each state is pre-tokenized at build
|
|
18
|
+
* time (see the `code` macro). Consecutive states are line-diffed so unchanged
|
|
19
|
+
* lines keep identity and FLIP to their new position while edits fade in/out. The
|
|
20
|
+
* deck's reveal steps drive which state shows (via `useRevealState`); in a static
|
|
21
|
+
* render (thumbnail/standalone) it shows the final state. */
|
|
22
|
+
export function CodeMagic({
|
|
23
|
+
steps,
|
|
24
|
+
title,
|
|
25
|
+
lang,
|
|
26
|
+
}: {
|
|
27
|
+
steps: TokenizedStep[];
|
|
28
|
+
title?: string;
|
|
29
|
+
lang?: string;
|
|
30
|
+
}) {
|
|
31
|
+
const keyed = useMemo(() => keySteps(steps), [steps]);
|
|
32
|
+
const state = useRevealState(steps.length);
|
|
33
|
+
const active = keyed[Math.max(0, Math.min(keyed.length - 1, state))] ?? { lines: [] };
|
|
34
|
+
const language = lang ?? steps[0]?.lang;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="pi-code" data-code>
|
|
38
|
+
<div className="pi-code-bar">
|
|
39
|
+
<span className="pi-code-dots" aria-hidden>
|
|
40
|
+
<i />
|
|
41
|
+
<i />
|
|
42
|
+
<i />
|
|
43
|
+
</span>
|
|
44
|
+
{title && <span className="pi-code-title">{title}</span>}
|
|
45
|
+
{language && <span className="pi-code-lang">{language}</span>}
|
|
46
|
+
</div>
|
|
47
|
+
<pre className="pi-codemagic">
|
|
48
|
+
<code>
|
|
49
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
50
|
+
{active.lines.map((line) => (
|
|
51
|
+
<motion.div
|
|
52
|
+
key={line.key}
|
|
53
|
+
layout
|
|
54
|
+
className="pi-code-line"
|
|
55
|
+
initial={{ opacity: 0, x: -10 }}
|
|
56
|
+
animate={{ opacity: 1, x: 0 }}
|
|
57
|
+
exit={{ opacity: 0, x: 10 }}
|
|
58
|
+
transition={{ type: "spring", stiffness: 320, damping: 34 }}
|
|
59
|
+
>
|
|
60
|
+
{line.tokens.length === 0 ? (
|
|
61
|
+
<span>{" "}</span>
|
|
62
|
+
) : (
|
|
63
|
+
line.tokens.map((tok, i) => (
|
|
64
|
+
<span key={i} style={styleOf(tok.color, tok.fontStyle)}>
|
|
65
|
+
{tok.content}
|
|
66
|
+
</span>
|
|
67
|
+
))
|
|
68
|
+
)}
|
|
69
|
+
</motion.div>
|
|
70
|
+
))}
|
|
71
|
+
</AnimatePresence>
|
|
72
|
+
</code>
|
|
73
|
+
</pre>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
package/src/Deck.tsx
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
3
|
+
import { MDXProvider } from "@mdx-js/react";
|
|
4
|
+
import { mdxComponents } from "@liebstoeckel/components";
|
|
5
|
+
import * as Y from "yjs";
|
|
6
|
+
import type { PluginDef } from "@liebstoeckel/plugin-sdk";
|
|
7
|
+
import { useDeckNav, useTouchNav } from "./nav";
|
|
8
|
+
import { PortraitHint } from "./MobileHint";
|
|
9
|
+
import { useDeckSync } from "./useDeckSync";
|
|
10
|
+
import { useLive } from "./live/Plugin";
|
|
11
|
+
import { useLiveDeck } from "./live/deckIndex";
|
|
12
|
+
import { PersistentProvider, PersistentLayer, type PersistentItem } from "./PersistentLayer";
|
|
13
|
+
import { ScaledStage, SlideFrame } from "./Stage";
|
|
14
|
+
import { DeckThumb } from "./Thumb";
|
|
15
|
+
import { readThumbnails } from "./thumbnails";
|
|
16
|
+
import { hasEmbeddedSource } from "./source";
|
|
17
|
+
import { HelpOverlay } from "./HelpOverlay";
|
|
18
|
+
import { QrOverlay } from "./QrOverlay";
|
|
19
|
+
import { PluginOverlays } from "./live/globalChrome";
|
|
20
|
+
import { DeckChrome } from "./DeckChrome";
|
|
21
|
+
import { StepsProvider } from "./steps";
|
|
22
|
+
import { accumulateDigits, toggleFullscreen } from "./delivery";
|
|
23
|
+
import { normalizeSlides, type SlideInput } from "./slides";
|
|
24
|
+
import { resolveTransition, mobileTransitionsDisabled, type SlideDirection, type SlideTransition } from "./transitions";
|
|
25
|
+
import type { Theme } from "@liebstoeckel/theme";
|
|
26
|
+
import { useCoarsePointer } from "./useCoarsePointer";
|
|
27
|
+
|
|
28
|
+
export type DeckProps = {
|
|
29
|
+
slides: SlideInput[];
|
|
30
|
+
persistent?: PersistentItem[];
|
|
31
|
+
brands?: string[];
|
|
32
|
+
title?: string;
|
|
33
|
+
/** Deck-wide default slide transition. A slide can override it with its own
|
|
34
|
+
* `export const transition`. Defaults to a light `"fade"`. */
|
|
35
|
+
transition?: SlideTransition;
|
|
36
|
+
/** Deck-defined brand themes. Each is injected as a `[data-brand]` CSS block, so a
|
|
37
|
+
* deck can ship its own brand(s) without editing the theme package, reference
|
|
38
|
+
* them by `name` in `brands`. (Built-in brand names work via the theme styles
|
|
39
|
+
* with no entry here.) */
|
|
40
|
+
brandThemes?: Theme[];
|
|
41
|
+
/** Allow slide transitions on mobile (coarse-pointer) devices. Off by default, * transitions are dropped there for snappier, jank-free navigation. Set true to
|
|
42
|
+
* opt back in. */
|
|
43
|
+
mobileTransitions?: boolean;
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
plugins?: PluginDef<any>[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function openPresenter() {
|
|
49
|
+
// preserve the query (incl. ?t=<token>) so a live presenter window authenticates
|
|
50
|
+
const url = location.origin + location.pathname + location.search + "#presenter";
|
|
51
|
+
// window.open can throw (relay sandbox without allow-popups) or return null (a
|
|
52
|
+
// popup blocker), never let that bubble up as an uncaught DOMException.
|
|
53
|
+
try {
|
|
54
|
+
const w = window.open(url, "liebstoeckel-presenter", "width=1366,height=860");
|
|
55
|
+
if (!w) console.warn("[liebstoeckel] presenter pop-out was blocked (popup blocker or sandbox).");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.warn("[liebstoeckel] presenter pop-out is unavailable in this context:", err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Deck({ slides, persistent = [], brands = ["default"], transition: deckTransition, mobileTransitions }: DeckProps) {
|
|
62
|
+
const norm = useMemo(() => normalizeSlides(slides), [slides]);
|
|
63
|
+
const count = norm.length;
|
|
64
|
+
// Pre-rendered overview thumbnails (build-time), if the deck embedded them.
|
|
65
|
+
const thumbs = useMemo(() => readThumbnails(), []);
|
|
66
|
+
|
|
67
|
+
// Index/step source: shared Yjs doc in a live session (viewers follow), else
|
|
68
|
+
// BroadcastChannel across local windows. Both hooks run; we select one.
|
|
69
|
+
const liveCtx = useLive();
|
|
70
|
+
const fallbackDoc = useMemo(() => new Y.Doc(), []);
|
|
71
|
+
const sync = useDeckSync(count);
|
|
72
|
+
const liveDeck = useLiveDeck(liveCtx?.doc ?? fallbackDoc, count, liveCtx?.role !== "viewer");
|
|
73
|
+
const isLive = !!liveCtx?.live;
|
|
74
|
+
const role = isLive ? liveCtx?.role : undefined;
|
|
75
|
+
// A live viewer follows the presenter, overview + the presenter pop-out are
|
|
76
|
+
// presenter-only (they'd reach the confidence monitor / drive nav). Standalone
|
|
77
|
+
// (no live role) drives its own deck, so canDrive is true (ADR 0070).
|
|
78
|
+
const canDrive = role !== "viewer";
|
|
79
|
+
const ctrl = isLive ? liveDeck : sync;
|
|
80
|
+
const { index, step, total } = ctrl;
|
|
81
|
+
|
|
82
|
+
// Gate total-reporting by the current slide so the *exiting* slide (during the
|
|
83
|
+
// AnimatePresence overlap) can't clobber the entering slide's step count.
|
|
84
|
+
const indexRef = useRef(index);
|
|
85
|
+
indexRef.current = index;
|
|
86
|
+
const onTotal = useCallback(
|
|
87
|
+
(slideIndex: number, n: number) => {
|
|
88
|
+
if (slideIndex === indexRef.current) ctrl.setTotal(n);
|
|
89
|
+
},
|
|
90
|
+
[ctrl],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const [brandIdx, setBrandIdx] = useState(0);
|
|
94
|
+
const [help, setHelp] = useState(false);
|
|
95
|
+
// Static for the loaded document; resolve once for the help overlay's eject hint.
|
|
96
|
+
const ejectable = useMemo(() => hasEmbeddedSource(), []);
|
|
97
|
+
const [blurred, setBlurred] = useState(false);
|
|
98
|
+
const [overview, setOverview] = useState(false);
|
|
99
|
+
const [qr, setQr] = useState(false);
|
|
100
|
+
const [jump, setJump] = useState("");
|
|
101
|
+
const brand = brands[brandIdx % brands.length];
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
document.body.dataset.brand = brand;
|
|
105
|
+
}, [brand]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const onCtx = (e: MouseEvent) => {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
setHelp((v) => !v);
|
|
111
|
+
};
|
|
112
|
+
window.addEventListener("contextmenu", onCtx);
|
|
113
|
+
return () => window.removeEventListener("contextmenu", onCtx);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const onDigit = useCallback(
|
|
117
|
+
(key: string) => {
|
|
118
|
+
const r = accumulateDigits(jump, key);
|
|
119
|
+
setJump(r.buffer);
|
|
120
|
+
if (r.commit != null) ctrl.setIndex(r.commit);
|
|
121
|
+
},
|
|
122
|
+
[jump, ctrl],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
useDeckNav({
|
|
126
|
+
count,
|
|
127
|
+
setIndex: ctrl.setIndex,
|
|
128
|
+
onNext: ctrl.next,
|
|
129
|
+
onPrev: ctrl.prev,
|
|
130
|
+
onToggleBrand: brands.length > 1 ? () => setBrandIdx((n) => n + 1) : undefined,
|
|
131
|
+
onOpenPresenter: canDrive ? openPresenter : undefined,
|
|
132
|
+
onToggleHelp: () => setHelp((v) => !v),
|
|
133
|
+
onFullscreen: () => void toggleFullscreen(document.documentElement),
|
|
134
|
+
onBlur: () => setBlurred((v) => !v),
|
|
135
|
+
onOverview: canDrive ? () => setOverview((v) => !v) : undefined,
|
|
136
|
+
onQr: () => setQr((v) => !v),
|
|
137
|
+
onDigit,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Touch nav for everyone who drives their own deck (standalone + presenter); a
|
|
141
|
+
// live viewer follows the presenter, so it isn't bound for them.
|
|
142
|
+
useTouchNav({ enabled: role !== "viewer", onNext: ctrl.next, onPrev: ctrl.prev });
|
|
143
|
+
|
|
144
|
+
const Current = norm[index]?.Component ?? (() => null);
|
|
145
|
+
|
|
146
|
+
// Slide transition: per-slide `transition` export wins over the deck default
|
|
147
|
+
// (which defaults to "fade"). `direction` mirrors directional presets on
|
|
148
|
+
// back-nav; reduced-motion collapses everything to a tiny opacity fade.
|
|
149
|
+
const reduceMotion = useReducedMotion();
|
|
150
|
+
const coarse = useCoarsePointer();
|
|
151
|
+
const prevIndexRef = useRef(index);
|
|
152
|
+
const direction: SlideDirection = index >= prevIndexRef.current ? 1 : -1;
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
prevIndexRef.current = index;
|
|
155
|
+
}, [index]);
|
|
156
|
+
// Mobile (coarse pointer) drops transitions by default, opt back in with
|
|
157
|
+
// `mobileTransitions`. Otherwise: per-slide override → deck default → "fade".
|
|
158
|
+
const requested = mobileTransitionsDisabled(coarse, mobileTransitions)
|
|
159
|
+
? "none"
|
|
160
|
+
: (norm[index]?.transition ?? deckTransition);
|
|
161
|
+
const slideTransition = resolveTransition(requested, !!reduceMotion);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<MDXProvider components={mdxComponents}>
|
|
165
|
+
<PersistentProvider>
|
|
166
|
+
{/* 100dvh (dynamic viewport), NOT 100vh: on mobile the browser's address
|
|
167
|
+
bar makes 100vh taller than the visible area, which would push the slide
|
|
168
|
+
bottom + the viewport-pinned chrome below the fold. */}
|
|
169
|
+
<div className="relative h-dvh w-screen overflow-hidden bg-bg">
|
|
170
|
+
<ScaledStage className="absolute inset-0">
|
|
171
|
+
<div data-deck-root className="absolute inset-0">
|
|
172
|
+
<AnimatePresence custom={direction}>
|
|
173
|
+
<motion.div
|
|
174
|
+
key={index}
|
|
175
|
+
custom={direction}
|
|
176
|
+
className="absolute inset-0"
|
|
177
|
+
variants={slideTransition.variants}
|
|
178
|
+
initial="enter"
|
|
179
|
+
animate="center"
|
|
180
|
+
exit="exit"
|
|
181
|
+
transition={slideTransition.transition}
|
|
182
|
+
>
|
|
183
|
+
<SlideFrame>
|
|
184
|
+
<StepsProvider step={step} slideIndex={index} onTotal={onTotal}>
|
|
185
|
+
<Current />
|
|
186
|
+
</StepsProvider>
|
|
187
|
+
</SlideFrame>
|
|
188
|
+
</motion.div>
|
|
189
|
+
</AnimatePresence>
|
|
190
|
+
|
|
191
|
+
<PersistentLayer items={persistent} currentIndex={index} />
|
|
192
|
+
|
|
193
|
+
{/* deck-wide plugin overlays (e.g. reactions floaters), over the
|
|
194
|
+
slide, below chrome; non-interactive (live only, see ADR 0021) */}
|
|
195
|
+
<PluginOverlays />
|
|
196
|
+
|
|
197
|
+
{/* jump-to-number buffer */}
|
|
198
|
+
<AnimatePresence>
|
|
199
|
+
{jump && (
|
|
200
|
+
<motion.div
|
|
201
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
202
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
203
|
+
exit={{ opacity: 0 }}
|
|
204
|
+
className="absolute left-1/2 top-8 -translate-x-1/2 rounded-xl border border-border bg-surface/80 px-5 py-2 font-mono text-2xl text-text backdrop-blur"
|
|
205
|
+
>
|
|
206
|
+
→ {jump}
|
|
207
|
+
</motion.div>
|
|
208
|
+
)}
|
|
209
|
+
</AnimatePresence>
|
|
210
|
+
|
|
211
|
+
<HelpOverlay open={help} onClose={() => setHelp(false)} showBrand={brands.length > 1} role={role} ejectable={ejectable} />
|
|
212
|
+
<PortraitHint />
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{/* blur-screen overlay */}
|
|
216
|
+
<AnimatePresence>
|
|
217
|
+
{blurred && (
|
|
218
|
+
<motion.div
|
|
219
|
+
className="absolute inset-0 flex items-center justify-center backdrop-blur-2xl"
|
|
220
|
+
style={{ background: "color-mix(in srgb, var(--brand-bg) 55%, transparent)" }}
|
|
221
|
+
initial={{ opacity: 0 }}
|
|
222
|
+
animate={{ opacity: 1 }}
|
|
223
|
+
exit={{ opacity: 0 }}
|
|
224
|
+
onClick={() => setBlurred(false)}
|
|
225
|
+
>
|
|
226
|
+
<svg width="84" height="84" viewBox="0 0 24 24" fill="none" stroke="var(--brand-muted)" strokeWidth="1.4" opacity="0.5">
|
|
227
|
+
<path d="M2 12s3.5-6 10-6 10 6 10 6" strokeLinecap="round" />
|
|
228
|
+
<path d="M2 12s3.5 4 10 4 10-4 10-4" strokeLinecap="round" />
|
|
229
|
+
</svg>
|
|
230
|
+
</motion.div>
|
|
231
|
+
)}
|
|
232
|
+
</AnimatePresence>
|
|
233
|
+
</ScaledStage>
|
|
234
|
+
|
|
235
|
+
{/* QR + overview render OUTSIDE the scaled canvas (device scale) so they're
|
|
236
|
+
full-size on a phone, the touch ⋮ menu opens them. */}
|
|
237
|
+
<QrOverlay open={qr} url={liveCtx?.viewerUrl} onClose={() => setQr(false)} />
|
|
238
|
+
<AnimatePresence>
|
|
239
|
+
{overview && (
|
|
240
|
+
<motion.div
|
|
241
|
+
className="absolute inset-0 z-40 overflow-auto bg-bg/95 p-[4%] backdrop-blur-xl"
|
|
242
|
+
initial={{ opacity: 0 }}
|
|
243
|
+
animate={{ opacity: 1 }}
|
|
244
|
+
exit={{ opacity: 0 }}
|
|
245
|
+
>
|
|
246
|
+
<div className="mb-6 font-mono text-sm uppercase tracking-[0.3em] text-muted">Overview · tap or type a number</div>
|
|
247
|
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 lg:gap-5">
|
|
248
|
+
{norm.map((s, i) => (
|
|
249
|
+
<button
|
|
250
|
+
key={i}
|
|
251
|
+
onClick={() => {
|
|
252
|
+
ctrl.setIndex(i);
|
|
253
|
+
setOverview(false);
|
|
254
|
+
}}
|
|
255
|
+
className={`relative aspect-video overflow-hidden rounded-xl border text-left transition ${
|
|
256
|
+
i === index ? "border-primary" : "border-border hover:border-text"
|
|
257
|
+
}`}
|
|
258
|
+
>
|
|
259
|
+
<DeckThumb Component={s.Component} src={thumbs?.get(i)} alt={`Slide ${i + 1}`} />
|
|
260
|
+
<span className="absolute bottom-1 right-2 font-mono text-xs text-muted">{i + 1}</span>
|
|
261
|
+
</button>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
</motion.div>
|
|
265
|
+
)}
|
|
266
|
+
</AnimatePresence>
|
|
267
|
+
|
|
268
|
+
{/* chrome at DEVICE scale, pinned to the real viewport (outside the scaled
|
|
269
|
+
canvas) so the help + plugin buttons stay tappable on a phone. On touch
|
|
270
|
+
the help affordance becomes a ⋮ action menu (fullscreen, overview, …). */}
|
|
271
|
+
<DeckChrome
|
|
272
|
+
index={index}
|
|
273
|
+
count={count}
|
|
274
|
+
isLive={isLive}
|
|
275
|
+
role={role}
|
|
276
|
+
canDrive={canDrive}
|
|
277
|
+
viewerUrl={liveCtx?.viewerUrl}
|
|
278
|
+
onHelp={() => setHelp(true)}
|
|
279
|
+
onOverview={() => setOverview((v) => !v)}
|
|
280
|
+
onQr={() => setQr((v) => !v)}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
</PersistentProvider>
|
|
284
|
+
</MDXProvider>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { AnimatePresence } from "motion/react";
|
|
3
|
+
import { usePluginChrome } from "./live/globalChrome";
|
|
4
|
+
import { BreakoutSheet } from "./live/breakout";
|
|
5
|
+
import { useCoarsePointer } from "./useCoarsePointer";
|
|
6
|
+
import { toggleFullscreen } from "./delivery";
|
|
7
|
+
|
|
8
|
+
function useIsFullscreen(): boolean {
|
|
9
|
+
const [fs, setFs] = useState(false);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const h = () => setFs(typeof document !== "undefined" && !!document.fullscreenElement);
|
|
12
|
+
h();
|
|
13
|
+
document.addEventListener("fullscreenchange", h);
|
|
14
|
+
return () => document.removeEventListener("fullscreenchange", h);
|
|
15
|
+
}, []);
|
|
16
|
+
return fs;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ICON = {
|
|
20
|
+
more: (
|
|
21
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
22
|
+
<circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" />
|
|
23
|
+
</svg>
|
|
24
|
+
),
|
|
25
|
+
enterFs: (
|
|
26
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
27
|
+
<path d="M4 9V5a1 1 0 0 1 1-1h4M20 9V5a1 1 0 0 0-1-1h-4M4 15v4a1 1 0 0 0 1 1h4M20 15v4a1 1 0 0 1-1 1h-4" />
|
|
28
|
+
</svg>
|
|
29
|
+
),
|
|
30
|
+
exitFs: (
|
|
31
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
32
|
+
<path d="M9 4v3a2 2 0 0 1-2 2H4M15 4v3a2 2 0 0 0 2 2h3M9 20v-3a2 2 0 0 0-2-2H4M15 20v-3a2 2 0 0 1 2-2h3" />
|
|
33
|
+
</svg>
|
|
34
|
+
),
|
|
35
|
+
grid: (
|
|
36
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
37
|
+
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
|
38
|
+
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
|
39
|
+
</svg>
|
|
40
|
+
),
|
|
41
|
+
share: (
|
|
42
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
43
|
+
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
|
44
|
+
<path d="M14 3h7v7M3 14h4v4M10 14h0M14 10h0" />
|
|
45
|
+
</svg>
|
|
46
|
+
),
|
|
47
|
+
theme: (
|
|
48
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
49
|
+
<path d="M12 3a9 9 0 1 0 0 18 2.5 2.5 0 0 0 0-5h-1a2 2 0 0 1 0-4h3a4 4 0 0 0 4-4 5 5 0 0 0-9-3Z" />
|
|
50
|
+
</svg>
|
|
51
|
+
),
|
|
52
|
+
notes: (
|
|
53
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
54
|
+
<rect x="3" y="4" width="18" height="14" rx="1.5" />
|
|
55
|
+
<path d="M7 9h10M7 13h6" />
|
|
56
|
+
</svg>
|
|
57
|
+
),
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
interface MenuAction {
|
|
61
|
+
key: string;
|
|
62
|
+
label: string;
|
|
63
|
+
icon: ReactNode;
|
|
64
|
+
onClick: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function MenuRow({ action, onDone }: { action: MenuAction; onDone: () => void }) {
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
onClick={() => {
|
|
71
|
+
action.onClick();
|
|
72
|
+
onDone();
|
|
73
|
+
}}
|
|
74
|
+
style={{
|
|
75
|
+
appearance: "none",
|
|
76
|
+
cursor: "pointer",
|
|
77
|
+
display: "flex",
|
|
78
|
+
alignItems: "center",
|
|
79
|
+
gap: "0.9rem",
|
|
80
|
+
width: "100%",
|
|
81
|
+
padding: "0.95rem 0.5rem",
|
|
82
|
+
border: "none",
|
|
83
|
+
borderTop: "1px solid color-mix(in srgb, var(--brand-text, #e9e6d7) 8%, transparent)",
|
|
84
|
+
background: "transparent",
|
|
85
|
+
color: "var(--brand-text, #e9e6d7)",
|
|
86
|
+
fontFamily: "var(--brand-font-body, sans-serif)",
|
|
87
|
+
fontSize: "1.05rem",
|
|
88
|
+
textAlign: "left",
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<span style={{ color: "var(--brand-muted, #9da28c)", display: "flex" }}>{action.icon}</span>
|
|
92
|
+
{action.label}
|
|
93
|
+
</button>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Touch action menu: a `⋮` button opening a sheet of tappable deck actions. */
|
|
98
|
+
function DeckMenu({ actions }: { actions: MenuAction[] }) {
|
|
99
|
+
const [open, setOpen] = useState(false);
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => setOpen(true)}
|
|
104
|
+
aria-label="Menu"
|
|
105
|
+
className="flex h-7 w-7 items-center justify-center rounded-full border border-border text-muted/70 transition hover:border-text hover:text-text"
|
|
106
|
+
>
|
|
107
|
+
{ICON.more}
|
|
108
|
+
</button>
|
|
109
|
+
<AnimatePresence>
|
|
110
|
+
{open && (
|
|
111
|
+
<BreakoutSheet label="Menu" onClose={() => setOpen(false)}>
|
|
112
|
+
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
113
|
+
{actions.map((a) => (
|
|
114
|
+
<MenuRow key={a.key} action={a} onDone={() => setOpen(false)} />
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</BreakoutSheet>
|
|
118
|
+
)}
|
|
119
|
+
</AnimatePresence>
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Deck chrome rendered at DEVICE scale, pinned to the real viewport, outside the
|
|
125
|
+
* scaled 1280×720 canvas, so the buttons stay tappable on a phone. Carries the
|
|
126
|
+
* progress bar, the slide counter, the help/menu affordance, and any plugin-
|
|
127
|
+
* registered global controls (ADR 0021). On a coarse pointer the rail is upsized
|
|
128
|
+
* and the keyboard-shortcut help is swapped for a tappable `⋮` action menu. */
|
|
129
|
+
export function DeckChrome({
|
|
130
|
+
index,
|
|
131
|
+
count,
|
|
132
|
+
isLive,
|
|
133
|
+
role,
|
|
134
|
+
canDrive,
|
|
135
|
+
viewerUrl,
|
|
136
|
+
onHelp,
|
|
137
|
+
onOverview,
|
|
138
|
+
onQr,
|
|
139
|
+
}: {
|
|
140
|
+
index: number;
|
|
141
|
+
count: number;
|
|
142
|
+
isLive: boolean;
|
|
143
|
+
role: string | undefined;
|
|
144
|
+
canDrive: boolean;
|
|
145
|
+
viewerUrl: string | undefined;
|
|
146
|
+
onHelp: () => void;
|
|
147
|
+
onOverview: () => void;
|
|
148
|
+
onQr: () => void;
|
|
149
|
+
}) {
|
|
150
|
+
const coarse = useCoarsePointer();
|
|
151
|
+
const fs = useIsFullscreen();
|
|
152
|
+
const inset = "max(env(safe-area-inset-left), 1rem)";
|
|
153
|
+
// plugin global controls: pinned ones sit in the rail; the rest overflow into the ⋮
|
|
154
|
+
// menu on touch so the rail can't run off-screen (ADR 0038). Panels are hosted here.
|
|
155
|
+
const { rail: pluginRail, menuActions: pluginMenu, panels: pluginPanels } = usePluginChrome();
|
|
156
|
+
|
|
157
|
+
// Switch this window to the presenter view (ADR 0027), the touch counterpart to
|
|
158
|
+
// the desktop `P` pop-out. `Present` selects the view by the #presenter hash at
|
|
159
|
+
// mount, so set the hash and reload.
|
|
160
|
+
const openPresenterView = () => {
|
|
161
|
+
if (typeof location === "undefined") return;
|
|
162
|
+
location.hash = "presenter";
|
|
163
|
+
location.reload();
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Note: no "Cycle theme" here, brand cycling stays the desktop `t` shortcut
|
|
167
|
+
// (a niche preview/showcase affordance), not a touch action.
|
|
168
|
+
const actions: MenuAction[] = [
|
|
169
|
+
{
|
|
170
|
+
key: "fullscreen",
|
|
171
|
+
label: fs ? "Exit fullscreen" : "Fullscreen",
|
|
172
|
+
icon: fs ? ICON.exitFs : ICON.enterFs,
|
|
173
|
+
onClick: () => void toggleFullscreen(document.documentElement),
|
|
174
|
+
},
|
|
175
|
+
...(canDrive ? [{ key: "overview", label: "Overview", icon: ICON.grid, onClick: onOverview }] : []),
|
|
176
|
+
// Presenter view (notes-first confidence monitor), drivers only, never viewers.
|
|
177
|
+
...(canDrive ? [{ key: "presenter", label: "Presenter view", icon: ICON.notes, onClick: openPresenterView }] : []),
|
|
178
|
+
...(isLive && viewerUrl ? [{ key: "share", label: "Share / QR", icon: ICON.share, onClick: onQr }] : []),
|
|
179
|
+
...pluginMenu, // overflowed (unpinned) plugin controls, touch only
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<>
|
|
184
|
+
{/* progress + counter pinned to the viewport bottom */}
|
|
185
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 h-[3px] bg-border/40">
|
|
186
|
+
<div
|
|
187
|
+
className="h-full bg-primary transition-[width] duration-500 ease-out"
|
|
188
|
+
style={{ width: `${((index + 1) / count) * 100}%` }}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<div
|
|
192
|
+
className="pointer-events-none absolute z-30 font-mono text-sm tracking-wide text-muted"
|
|
193
|
+
style={{ bottom: "calc(0.9rem + env(safe-area-inset-bottom))", right: "max(env(safe-area-inset-right), 1.25rem)" }}
|
|
194
|
+
>
|
|
195
|
+
{String(index + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* chrome rail: help (desktop) or ⋮ menu (touch) + plugin global controls */}
|
|
199
|
+
<div
|
|
200
|
+
className="absolute z-30 flex items-center gap-2"
|
|
201
|
+
style={{
|
|
202
|
+
bottom: "calc(0.75rem + env(safe-area-inset-bottom))",
|
|
203
|
+
left: inset,
|
|
204
|
+
transform: coarse ? "scale(1.6)" : "none",
|
|
205
|
+
transformOrigin: "bottom left",
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{coarse ? (
|
|
209
|
+
<DeckMenu actions={actions} />
|
|
210
|
+
) : (
|
|
211
|
+
<button
|
|
212
|
+
onClick={onHelp}
|
|
213
|
+
title={isLive ? `${role} · shortcuts (? or right-click)` : "Shortcuts (? or right-click)"}
|
|
214
|
+
className={`flex h-7 w-7 items-center justify-center rounded-full border transition ${
|
|
215
|
+
isLive
|
|
216
|
+
? "border-accent/50 text-accent opacity-80 hover:opacity-100"
|
|
217
|
+
: "border-border text-muted/50 opacity-60 hover:border-text hover:text-text hover:opacity-100"
|
|
218
|
+
}`}
|
|
219
|
+
>
|
|
220
|
+
{!isLive ? (
|
|
221
|
+
<span className="font-mono text-xs">?</span>
|
|
222
|
+
) : role === "viewer" ? (
|
|
223
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
224
|
+
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
|
225
|
+
<circle cx="12" cy="12" r="3" />
|
|
226
|
+
</svg>
|
|
227
|
+
) : (
|
|
228
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
229
|
+
<rect x="3" y="4" width="18" height="13" rx="1.5" />
|
|
230
|
+
<path d="M12 17v3M8.5 20h7" />
|
|
231
|
+
</svg>
|
|
232
|
+
)}
|
|
233
|
+
</button>
|
|
234
|
+
)}
|
|
235
|
+
{pluginRail}
|
|
236
|
+
</div>
|
|
237
|
+
{pluginPanels}
|
|
238
|
+
</>
|
|
239
|
+
);
|
|
240
|
+
}
|