@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,235 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { StageScaleContext } from "../Stage";
|
|
5
|
+
import { useCoarsePointer } from "../useCoarsePointer";
|
|
6
|
+
import { breakoutEligible } from "../mobile";
|
|
7
|
+
|
|
8
|
+
/** Set false to suppress the touch breakout for plugins in a subtree (e.g. the
|
|
9
|
+
* presenter's non-interactive slide preview). */
|
|
10
|
+
export const BreakoutAllowedContext = createContext(true);
|
|
11
|
+
|
|
12
|
+
/** Whether a plugin should offer tap-to-expand instead of inline interaction. */
|
|
13
|
+
export function useBreakoutEligible(interactive: boolean): boolean {
|
|
14
|
+
const scale = useContext(StageScaleContext);
|
|
15
|
+
const allowed = useContext(BreakoutAllowedContext);
|
|
16
|
+
const coarse = useCoarsePointer();
|
|
17
|
+
return breakoutEligible({ allowed, coarse, scale, interactive });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A pulsing brand-accent ring + "tap to interact" pill around a (non-interactive)
|
|
21
|
+
* plugin preview. Tapping anywhere opens the full-size breakout. */
|
|
22
|
+
export function GlowTap({ label, onOpen, children }: { label: string; onOpen: () => void; children: ReactNode }) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
role="button"
|
|
26
|
+
tabIndex={0}
|
|
27
|
+
aria-label={`${label}, tap to interact`}
|
|
28
|
+
data-pi-no-nav
|
|
29
|
+
onClick={onOpen}
|
|
30
|
+
onKeyDown={(e) => {
|
|
31
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
onOpen();
|
|
34
|
+
}
|
|
35
|
+
}}
|
|
36
|
+
style={{ position: "relative", cursor: "pointer", borderRadius: "1.1rem", display: "inline-block", maxWidth: "100%" }}
|
|
37
|
+
>
|
|
38
|
+
<motion.span
|
|
39
|
+
aria-hidden
|
|
40
|
+
style={{
|
|
41
|
+
position: "absolute",
|
|
42
|
+
inset: "-3px",
|
|
43
|
+
borderRadius: "1.2rem",
|
|
44
|
+
border: "1px solid var(--brand-accent, #e0c580)",
|
|
45
|
+
boxShadow: "0 0 26px -4px var(--brand-accent, #e0c580)",
|
|
46
|
+
pointerEvents: "none",
|
|
47
|
+
}}
|
|
48
|
+
animate={{ opacity: [0.45, 1, 0.45] }}
|
|
49
|
+
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
|
|
50
|
+
/>
|
|
51
|
+
<div style={{ pointerEvents: "none" }}>{children}</div>
|
|
52
|
+
<span
|
|
53
|
+
style={{
|
|
54
|
+
position: "absolute",
|
|
55
|
+
bottom: "-0.7rem",
|
|
56
|
+
left: "50%",
|
|
57
|
+
transform: "translateX(-50%)",
|
|
58
|
+
whiteSpace: "nowrap",
|
|
59
|
+
padding: "0.25rem 0.7rem",
|
|
60
|
+
borderRadius: "999px",
|
|
61
|
+
background: "var(--brand-accent, #e0c580)",
|
|
62
|
+
color: "var(--brand-on-primary, #10140e)",
|
|
63
|
+
fontFamily: "var(--brand-font-mono, monospace)",
|
|
64
|
+
fontSize: "0.7rem",
|
|
65
|
+
letterSpacing: "0.12em",
|
|
66
|
+
textTransform: "uppercase",
|
|
67
|
+
fontWeight: 600,
|
|
68
|
+
pointerEvents: "none",
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
tap to interact ↗
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True on a short viewport (landscape phone / small window), switch the breakout
|
|
78
|
+
* from a full-width bottom sheet to a centered, height-capped card so its content
|
|
79
|
+
* doesn't overflow off the bottom. */
|
|
80
|
+
function useShortViewport(): boolean {
|
|
81
|
+
const [short, setShort] = useState(false);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (typeof matchMedia !== "function") return;
|
|
84
|
+
const mq = matchMedia("(max-height: 600px)");
|
|
85
|
+
const update = () => setShort(mq.matches);
|
|
86
|
+
update();
|
|
87
|
+
mq.addEventListener?.("change", update);
|
|
88
|
+
return () => mq.removeEventListener?.("change", update);
|
|
89
|
+
}, []);
|
|
90
|
+
return short;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Track the **visual viewport**, the region NOT covered by the on-screen keyboard.
|
|
94
|
+
* On mobile the keyboard overlays the layout viewport (`100vh`/`inset:0` don't shrink),
|
|
95
|
+
* so a bottom-anchored sheet ends up behind it. Pinning to `visualViewport` keeps the
|
|
96
|
+
* sheet in the visible area. Returns null when unsupported (desktop / SSR) → caller
|
|
97
|
+
* falls back to full-viewport. */
|
|
98
|
+
function useVisualViewport(): { height: number; offsetTop: number } | null {
|
|
99
|
+
const [vp, setVp] = useState<{ height: number; offsetTop: number } | null>(null);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
|
|
102
|
+
if (!vv) return;
|
|
103
|
+
const update = () => setVp({ height: vv.height, offsetTop: vv.offsetTop });
|
|
104
|
+
update();
|
|
105
|
+
vv.addEventListener("resize", update);
|
|
106
|
+
vv.addEventListener("scroll", update);
|
|
107
|
+
return () => {
|
|
108
|
+
vv.removeEventListener("resize", update);
|
|
109
|
+
vv.removeEventListener("scroll", update);
|
|
110
|
+
};
|
|
111
|
+
}, []);
|
|
112
|
+
return vp;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A full-size breakout portaled OUTSIDE the scaled stage, so the plugin's real
|
|
116
|
+
* controls render at device scale with proper tap targets. Same Yjs state. A
|
|
117
|
+
* bottom sheet on tall viewports; a centered, scrollable card on short/landscape
|
|
118
|
+
* ones so the content never runs off-screen. */
|
|
119
|
+
export function BreakoutSheet({ label, onClose, children }: { label: string; onClose: () => void; children: ReactNode }) {
|
|
120
|
+
const short = useShortViewport();
|
|
121
|
+
const vp = useVisualViewport();
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const onKey = (e: KeyboardEvent) => {
|
|
124
|
+
if (e.key === "Escape") onClose();
|
|
125
|
+
};
|
|
126
|
+
window.addEventListener("keydown", onKey);
|
|
127
|
+
const prev = document.body.style.overflow;
|
|
128
|
+
document.body.style.overflow = "hidden";
|
|
129
|
+
return () => {
|
|
130
|
+
window.removeEventListener("keydown", onKey);
|
|
131
|
+
document.body.style.overflow = prev;
|
|
132
|
+
};
|
|
133
|
+
}, [onClose]);
|
|
134
|
+
|
|
135
|
+
if (typeof document === "undefined") return null;
|
|
136
|
+
|
|
137
|
+
return createPortal(
|
|
138
|
+
<motion.div
|
|
139
|
+
onClick={onClose}
|
|
140
|
+
initial={{ opacity: 0 }}
|
|
141
|
+
animate={{ opacity: 1 }}
|
|
142
|
+
exit={{ opacity: 0 }}
|
|
143
|
+
style={{
|
|
144
|
+
// pin to the visual viewport so the on-screen keyboard can't bury the sheet
|
|
145
|
+
// (it overlays the layout viewport; `inset:0` would sit behind it). ADR 0037.
|
|
146
|
+
position: "fixed",
|
|
147
|
+
left: 0,
|
|
148
|
+
right: 0,
|
|
149
|
+
top: vp ? vp.offsetTop : 0,
|
|
150
|
+
height: vp ? vp.height : "100%",
|
|
151
|
+
zIndex: 9999,
|
|
152
|
+
display: "flex",
|
|
153
|
+
flexDirection: "column",
|
|
154
|
+
justifyContent: short ? "center" : "flex-end",
|
|
155
|
+
alignItems: "center",
|
|
156
|
+
padding: short ? "max(env(safe-area-inset-top), 0.75rem) 0.75rem" : 0,
|
|
157
|
+
background: "color-mix(in srgb, var(--brand-bg, #10140e) 60%, transparent)",
|
|
158
|
+
backdropFilter: "blur(6px)",
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<motion.div
|
|
162
|
+
data-pi-breakout
|
|
163
|
+
onClick={(e) => e.stopPropagation()}
|
|
164
|
+
initial={{ y: short ? 24 : "100%", opacity: short ? 0 : 1 }}
|
|
165
|
+
animate={{ y: 0, opacity: 1 }}
|
|
166
|
+
exit={{ y: short ? 24 : "100%", opacity: short ? 0 : 1 }}
|
|
167
|
+
transition={{ type: "spring", stiffness: 320, damping: 36 }}
|
|
168
|
+
style={{
|
|
169
|
+
width: "100%",
|
|
170
|
+
maxWidth: short ? "560px" : "720px",
|
|
171
|
+
marginInline: "auto",
|
|
172
|
+
maxHeight: short ? "100%" : "min(88vh, 100%)",
|
|
173
|
+
overflow: "auto",
|
|
174
|
+
padding: short
|
|
175
|
+
? "0 1.1rem 1.1rem"
|
|
176
|
+
: "0 1.25rem calc(1.5rem + env(safe-area-inset-bottom))",
|
|
177
|
+
borderRadius: short ? "1.2rem" : "1.4rem 1.4rem 0 0",
|
|
178
|
+
background: "var(--brand-surface, #1a2014)",
|
|
179
|
+
border: short
|
|
180
|
+
? "1px solid var(--brand-border, color-mix(in srgb, var(--brand-text, #e9e6d7) 14%, transparent))"
|
|
181
|
+
: "none",
|
|
182
|
+
borderTop: "1px solid var(--brand-border, color-mix(in srgb, var(--brand-text, #e9e6d7) 14%, transparent))",
|
|
183
|
+
boxShadow: "0 -24px 60px -20px rgba(0,0,0,0.7)",
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{/* sticky header so the close button stays reachable while the body scrolls */}
|
|
187
|
+
<div
|
|
188
|
+
style={{
|
|
189
|
+
position: "sticky",
|
|
190
|
+
top: 0,
|
|
191
|
+
zIndex: 1,
|
|
192
|
+
display: "flex",
|
|
193
|
+
alignItems: "center",
|
|
194
|
+
justifyContent: "space-between",
|
|
195
|
+
padding: "1.1rem 0 0.9rem",
|
|
196
|
+
marginBottom: "0.4rem",
|
|
197
|
+
background: "var(--brand-surface, #1a2014)",
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<span
|
|
201
|
+
style={{
|
|
202
|
+
fontFamily: "var(--brand-font-mono, monospace)",
|
|
203
|
+
fontSize: "0.7rem",
|
|
204
|
+
letterSpacing: "0.22em",
|
|
205
|
+
textTransform: "uppercase",
|
|
206
|
+
color: "var(--brand-muted, #9da28c)",
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
{label}
|
|
210
|
+
</span>
|
|
211
|
+
<button
|
|
212
|
+
onClick={onClose}
|
|
213
|
+
aria-label="close"
|
|
214
|
+
style={{
|
|
215
|
+
appearance: "none",
|
|
216
|
+
cursor: "pointer",
|
|
217
|
+
border: "none",
|
|
218
|
+
background: "transparent",
|
|
219
|
+
color: "var(--brand-muted, #9da28c)",
|
|
220
|
+
fontSize: "1.2rem",
|
|
221
|
+
lineHeight: 1,
|
|
222
|
+
padding: "0.25rem 0.5rem",
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
✕
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
{/* shrink the plugin's content a touch on short viewports so it fits with
|
|
229
|
+
less scrolling (zoom reflows, unlike transform) */}
|
|
230
|
+
<div style={short ? ({ zoom: 0.85 } as CSSProperties) : undefined}>{children}</div>
|
|
231
|
+
</motion.div>
|
|
232
|
+
</motion.div>,
|
|
233
|
+
document.body,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import type { LiveInfo } from "./detect";
|
|
3
|
+
|
|
4
|
+
export interface LiveConnection {
|
|
5
|
+
doc: Y.Doc;
|
|
6
|
+
onStatus(cb: (connected: boolean) => void): void;
|
|
7
|
+
close(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ConnectOptions {
|
|
11
|
+
WS?: typeof WebSocket;
|
|
12
|
+
/** base reconnect delay (ms); backs off exponentially, capped */
|
|
13
|
+
reconnectBaseMs?: number;
|
|
14
|
+
reconnectMaxMs?: number;
|
|
15
|
+
/** force-reconnect if no frame (incl. server keepalives) arrives within this
|
|
16
|
+
* window, detects half-open sockets the browser won't `close`. 0 = disabled. */
|
|
17
|
+
staleMs?: number;
|
|
18
|
+
/** After this many consecutive failed (re)connect attempts the session is likely
|
|
19
|
+
* *gone* (re-provisioned to a new relay session) rather than a transient blip, so
|
|
20
|
+
* retrying the same URL will 403 forever (ADR 0071 §5 / ticket 0018). Fire
|
|
21
|
+
* `onUnrecoverable` once to recover via the stable link. 0 = never (default). */
|
|
22
|
+
reloadAfterAttempts?: number;
|
|
23
|
+
/** Recovery action when the session looks gone. Default: reload the page (which
|
|
24
|
+
* re-resolves the stable `/live/:slug` → the new relay session) where possible. */
|
|
25
|
+
onUnrecoverable?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Connect a Yjs doc to the live server over WebSocket, with auto-reconnect and a
|
|
29
|
+
* liveness watchdog. On (re)open it pushes local state up and the server replies
|
|
30
|
+
* with the full session state; thereafter updates flow both ways. Survives
|
|
31
|
+
* network blips and silently-dead (half-open) connections. */
|
|
32
|
+
export function connectLive(info: LiveInfo, participant: string, opts: ConnectOptions = {}): LiveConnection {
|
|
33
|
+
const WS = opts.WS ?? WebSocket;
|
|
34
|
+
const baseMs = opts.reconnectBaseMs ?? 1000;
|
|
35
|
+
const maxMs = opts.reconnectMaxMs ?? 15000;
|
|
36
|
+
const staleMs = opts.staleMs ?? 60000;
|
|
37
|
+
const reloadAfter = opts.reloadAfterAttempts ?? 0;
|
|
38
|
+
const onUnrecoverable =
|
|
39
|
+
opts.onUnrecoverable ??
|
|
40
|
+
(() => {
|
|
41
|
+
if (typeof location !== "undefined") location.reload();
|
|
42
|
+
});
|
|
43
|
+
let escalated = false;
|
|
44
|
+
const doc = new Y.Doc();
|
|
45
|
+
const sep = info.ws.includes("?") ? "&" : "?";
|
|
46
|
+
const url = `${info.ws}${sep}p=${encodeURIComponent(participant)}`;
|
|
47
|
+
const statusCbs: Array<(c: boolean) => void> = [];
|
|
48
|
+
const emit = (c: boolean) => statusCbs.forEach((cb) => cb(c));
|
|
49
|
+
|
|
50
|
+
let ws: WebSocket | null = null;
|
|
51
|
+
let closed = false;
|
|
52
|
+
let attempt = 0;
|
|
53
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
let lastMsgAt = Date.now();
|
|
55
|
+
let watchdog: ReturnType<typeof setInterval> | undefined;
|
|
56
|
+
|
|
57
|
+
const onUpdate = (update: Uint8Array, origin: unknown) => {
|
|
58
|
+
if (origin !== "remote" && ws && ws.readyState === ws.OPEN) ws.send(new Uint8Array(update));
|
|
59
|
+
};
|
|
60
|
+
doc.on("update", onUpdate);
|
|
61
|
+
|
|
62
|
+
const schedule = () => {
|
|
63
|
+
if (closed) return;
|
|
64
|
+
// Persistent failure → the session is likely gone (re-provisioned). Stop hammering
|
|
65
|
+
// the dead URL and escalate to stable-link recovery exactly once (ticket 0018).
|
|
66
|
+
if (reloadAfter > 0 && attempt >= reloadAfter && !escalated) {
|
|
67
|
+
escalated = true;
|
|
68
|
+
onUnrecoverable();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const delay = Math.min(baseMs * 2 ** attempt, maxMs);
|
|
72
|
+
attempt++;
|
|
73
|
+
timer = setTimeout(open, delay);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// watchdog: if the socket is OPEN but we've heard nothing within staleMs, the
|
|
77
|
+
// connection is likely half-open, drop it so `close` triggers a reconnect.
|
|
78
|
+
if (staleMs > 0) {
|
|
79
|
+
const period = Math.min(Math.max(Math.floor(staleMs / 3), 20), 30000);
|
|
80
|
+
watchdog = setInterval(() => {
|
|
81
|
+
if (closed || !ws || ws.readyState !== ws.OPEN) return;
|
|
82
|
+
if (Date.now() - lastMsgAt > staleMs) {
|
|
83
|
+
try {
|
|
84
|
+
ws.close();
|
|
85
|
+
} catch {
|
|
86
|
+
/* close handler reconnects */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}, period);
|
|
90
|
+
(watchdog as { unref?: () => void }).unref?.();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function open() {
|
|
94
|
+
if (closed) return;
|
|
95
|
+
const sock = new WS(url);
|
|
96
|
+
ws = sock;
|
|
97
|
+
sock.binaryType = "arraybuffer";
|
|
98
|
+
sock.addEventListener("open", () => {
|
|
99
|
+
attempt = 0;
|
|
100
|
+
lastMsgAt = Date.now();
|
|
101
|
+
try {
|
|
102
|
+
sock.send(new Uint8Array(Y.encodeStateAsUpdate(doc)));
|
|
103
|
+
} catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
emit(true);
|
|
107
|
+
});
|
|
108
|
+
sock.addEventListener("message", (e: MessageEvent) => {
|
|
109
|
+
lastMsgAt = Date.now(); // any frame (update or keepalive) proves liveness
|
|
110
|
+
try {
|
|
111
|
+
Y.applyUpdate(doc, new Uint8Array(e.data as ArrayBuffer), "remote");
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore malformed frame */
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
sock.addEventListener("close", () => {
|
|
117
|
+
emit(false);
|
|
118
|
+
schedule();
|
|
119
|
+
});
|
|
120
|
+
sock.addEventListener("error", () => {
|
|
121
|
+
try {
|
|
122
|
+
sock.close();
|
|
123
|
+
} catch {
|
|
124
|
+
/* the close handler will reconnect */
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
open();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
doc,
|
|
133
|
+
onStatus(cb) {
|
|
134
|
+
statusCbs.push(cb);
|
|
135
|
+
},
|
|
136
|
+
close() {
|
|
137
|
+
closed = true;
|
|
138
|
+
if (timer) clearTimeout(timer);
|
|
139
|
+
if (watchdog) clearInterval(watchdog);
|
|
140
|
+
doc.off("update", onUpdate);
|
|
141
|
+
try {
|
|
142
|
+
ws?.close();
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
doc.destroy();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { stepForward, stepBack } from "../delivery";
|
|
4
|
+
|
|
5
|
+
const clampN = (n: number, count: number) => Math.min(Math.max(n, 0), Math.max(count - 1, 0));
|
|
6
|
+
|
|
7
|
+
export const getDeckIndex = (doc: Y.Doc): number => (doc.getMap("deck").get("index") as number) ?? 0;
|
|
8
|
+
export const setDeckIndex = (doc: Y.Doc, n: number): void => {
|
|
9
|
+
doc.getMap("deck").set("index", n);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface DeckController {
|
|
13
|
+
index: number;
|
|
14
|
+
step: number;
|
|
15
|
+
total: number;
|
|
16
|
+
canDrive: boolean;
|
|
17
|
+
setIndex(u: number | ((n: number) => number)): void;
|
|
18
|
+
setStep(step: number): void;
|
|
19
|
+
setTotal(total: number): void;
|
|
20
|
+
next(): void;
|
|
21
|
+
prev(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Deck nav state backed by the shared doc: viewers follow, only `canDrive` (the
|
|
25
|
+
* presenter role) writes. Carries index + step + total so reveals follow too. */
|
|
26
|
+
export function useLiveDeck(doc: Y.Doc, count: number, canDrive: boolean): DeckController {
|
|
27
|
+
const map = doc.getMap<number>("deck");
|
|
28
|
+
const read = () => ({
|
|
29
|
+
index: (map.get("index") as number) ?? 0,
|
|
30
|
+
step: (map.get("step") as number) ?? 0,
|
|
31
|
+
total: (map.get("total") as number) ?? 0,
|
|
32
|
+
});
|
|
33
|
+
const [s, setS] = useState(read);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const handler = () => setS(read());
|
|
36
|
+
map.observe(handler);
|
|
37
|
+
handler();
|
|
38
|
+
return () => map.unobserve(handler);
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [doc]);
|
|
41
|
+
|
|
42
|
+
const setIndexTo = (n: number) =>
|
|
43
|
+
doc.transact(() => {
|
|
44
|
+
map.set("index", clampN(n, count));
|
|
45
|
+
map.set("step", 0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...s,
|
|
50
|
+
canDrive,
|
|
51
|
+
setIndex(u) {
|
|
52
|
+
if (!canDrive) return;
|
|
53
|
+
setIndexTo(typeof u === "function" ? u(read().index) : u);
|
|
54
|
+
},
|
|
55
|
+
setStep(step) {
|
|
56
|
+
if (canDrive) map.set("step", step);
|
|
57
|
+
},
|
|
58
|
+
setTotal(total) {
|
|
59
|
+
if (canDrive && read().total !== total) map.set("total", total);
|
|
60
|
+
},
|
|
61
|
+
// read freshest doc state so rapid presses don't act on a stale step
|
|
62
|
+
next() {
|
|
63
|
+
if (!canDrive) return;
|
|
64
|
+
const cur = read();
|
|
65
|
+
const r = stepForward(cur.step, cur.total);
|
|
66
|
+
if (r.advanceSlide) setIndexTo(cur.index + 1);
|
|
67
|
+
else map.set("step", r.step);
|
|
68
|
+
},
|
|
69
|
+
prev() {
|
|
70
|
+
if (!canDrive) return;
|
|
71
|
+
const cur = read();
|
|
72
|
+
const r = stepBack(cur.step);
|
|
73
|
+
if (r.retreatSlide) setIndexTo(cur.index - 1);
|
|
74
|
+
else map.set("step", r.step);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Role } from "@liebstoeckel/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export interface LiveInfo {
|
|
4
|
+
ws: string;
|
|
5
|
+
session: string;
|
|
6
|
+
role: Role;
|
|
7
|
+
token: string;
|
|
8
|
+
participant?: string;
|
|
9
|
+
/** read-only follow-along link, for the in-deck QR */
|
|
10
|
+
viewer?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read the bootstrap the live server injects. Absent → standalone (.html). */
|
|
14
|
+
export function detectLive(): LiveInfo | null {
|
|
15
|
+
const g = globalThis as { __LIEBSTOECKEL_LIVE__?: LiveInfo };
|
|
16
|
+
return g.__LIEBSTOECKEL_LIVE__ ?? null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
|
|
4
|
+
import { ChromeButton } from "@liebstoeckel/plugin-ui";
|
|
5
|
+
import { type ClientProps, type GlobalProps, type PluginDef } from "@liebstoeckel/plugin-sdk";
|
|
6
|
+
import { useLive, usePluginProps, type LiveContextValue } from "./Plugin";
|
|
7
|
+
import { PluginBoundary } from "./PluginBoundary";
|
|
8
|
+
import { BreakoutSheet } from "./breakout";
|
|
9
|
+
import { useCoarsePointer } from "../useCoarsePointer";
|
|
10
|
+
import { globalPlugins } from "./globals";
|
|
11
|
+
|
|
12
|
+
/** A row in the touch `⋮` menu (matches DeckChrome's MenuAction). */
|
|
13
|
+
export interface PluginMenuAction {
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
icon: ReactNode;
|
|
17
|
+
onClick: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PanelController {
|
|
21
|
+
open: boolean;
|
|
22
|
+
toggle: () => void;
|
|
23
|
+
close: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A lightweight popover for a global plugin Panel, portaled to device scale and
|
|
27
|
+
* anchored just above the chrome rail (bottom-left), with a transparent outside-
|
|
28
|
+
* click catcher and NO backdrop blur/dim. Unlike the focus-stealing `BreakoutSheet`
|
|
29
|
+
* (used for full interactive breakouts like Q&A), a quick, frequent action such as
|
|
30
|
+
* reacting shouldn't blur the whole deck behind it (ADR 0023). */
|
|
31
|
+
function ChromePopover({ onClose, children }: { onClose: () => void; children: ReactNode }) {
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const onKey = (e: KeyboardEvent) => {
|
|
34
|
+
if (e.key === "Escape") onClose();
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener("keydown", onKey);
|
|
37
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
38
|
+
}, [onClose]);
|
|
39
|
+
|
|
40
|
+
if (typeof document === "undefined") return null;
|
|
41
|
+
|
|
42
|
+
return createPortal(
|
|
43
|
+
<>
|
|
44
|
+
{/* transparent catcher: outside-tap closes, but the deck stays fully visible */}
|
|
45
|
+
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 9998, background: "transparent" }} />
|
|
46
|
+
<motion.div
|
|
47
|
+
data-pi-panel
|
|
48
|
+
onClick={(e) => e.stopPropagation()}
|
|
49
|
+
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
|
50
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
51
|
+
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
|
52
|
+
transition={{ type: "spring", stiffness: 420, damping: 32 }}
|
|
53
|
+
style={{
|
|
54
|
+
position: "fixed",
|
|
55
|
+
zIndex: 9999,
|
|
56
|
+
left: "max(env(safe-area-inset-left), 1rem)",
|
|
57
|
+
bottom: "calc(3.6rem + env(safe-area-inset-bottom))",
|
|
58
|
+
maxWidth: "min(22rem, calc(100vw - 2rem))",
|
|
59
|
+
padding: "1rem 1.15rem 1.15rem",
|
|
60
|
+
borderRadius: "1rem",
|
|
61
|
+
background: "var(--brand-surface, #11141b)",
|
|
62
|
+
border: "1px solid var(--brand-border, #222734)",
|
|
63
|
+
boxShadow: "0 18px 50px -18px rgba(0,0,0,0.7)",
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{children}
|
|
67
|
+
</motion.div>
|
|
68
|
+
</>,
|
|
69
|
+
document.body,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One plugin's ambient overlay (e.g. reactions floaters), inside the shared
|
|
74
|
+
* `pointer-events:none` layer. */
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
function OverlayItem({ ctx, id, def }: { ctx: LiveContextValue; id: string; def: PluginDef<any> }) {
|
|
77
|
+
const props = usePluginProps(ctx, id, def);
|
|
78
|
+
const Overlay = def.client.global?.Overlay as ComponentType<ClientProps<unknown>> | undefined;
|
|
79
|
+
// This overlay is mounted deck-wide on every slide: if it throws, it would unmount the
|
|
80
|
+
// whole deck. Contain it so a bad audience-written value can't white-screen everyone.
|
|
81
|
+
return Overlay ? (
|
|
82
|
+
<PluginBoundary resetKey={props.snapshot}>
|
|
83
|
+
<Overlay {...props} />
|
|
84
|
+
</PluginBoundary>
|
|
85
|
+
) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deck-wide overlay layer. Mounted as a child of `[data-deck-root]` so its
|
|
89
|
+
* contents are positioned over the slide; non-interactive by default. */
|
|
90
|
+
export function PluginOverlays() {
|
|
91
|
+
const ctx = useLive();
|
|
92
|
+
if (!ctx?.live) return null;
|
|
93
|
+
const entries = globalPlugins(ctx.plugins).filter((e) => e.def.client.global?.Overlay);
|
|
94
|
+
if (entries.length === 0) return null;
|
|
95
|
+
return (
|
|
96
|
+
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
|
97
|
+
{entries.map(({ id, def }) => (
|
|
98
|
+
<OverlayItem key={id} ctx={ctx} id={id} def={def} />
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A plugin's rail trigger: its custom `Control` if it has one, else a `ChromeButton`
|
|
105
|
+
* built from `icon` + `label`. Toggles the shared panel open-state. */
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
107
|
+
function RailTrigger({ ctx, id, def, panel }: { ctx: LiveContextValue; id: string; def: PluginDef<any>; panel: PanelController }) {
|
|
108
|
+
const base = usePluginProps(ctx, id, def);
|
|
109
|
+
const g = def.client.global!;
|
|
110
|
+
const Control = g.Control as ComponentType<GlobalProps<unknown>> | undefined;
|
|
111
|
+
if (Control) return <Control {...base} panel={panel} />;
|
|
112
|
+
return (
|
|
113
|
+
<ChromeButton onClick={panel.toggle} active={panel.open} title={g.label} ariaLabel={g.label}>
|
|
114
|
+
{g.icon}
|
|
115
|
+
</ChromeButton>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A plugin's panel, hosted centrally so it survives the `⋮` sheet closing and so its
|
|
120
|
+
* open-state can be driven from either the rail or a menu row. A `"sheet"` panel opens
|
|
121
|
+
* full-viewport on touch (keyboard-friendly, ADR 0037); otherwise a popover. */
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
123
|
+
function PanelHost({ ctx, id, def, open, onClose, coarse }: { ctx: LiveContextValue; id: string; def: PluginDef<any>; open: boolean; onClose: () => void; coarse: boolean }) {
|
|
124
|
+
const base = usePluginProps(ctx, id, def);
|
|
125
|
+
const Panel = def.client.global?.Panel as ComponentType<GlobalProps<unknown>> | undefined;
|
|
126
|
+
const gprops: GlobalProps<unknown> = { ...base, panel: { open, toggle: onClose, close: onClose } };
|
|
127
|
+
const sheet = def.client.global?.panelMode === "sheet" && coarse;
|
|
128
|
+
return (
|
|
129
|
+
<AnimatePresence>
|
|
130
|
+
{open &&
|
|
131
|
+
Panel &&
|
|
132
|
+
(sheet ? (
|
|
133
|
+
<BreakoutSheet label={def.client.global?.label ?? id} onClose={onClose}>
|
|
134
|
+
<LayoutGroup id={`plugin-panel:${id}`}>
|
|
135
|
+
<PluginBoundary resetKey={base.snapshot}>
|
|
136
|
+
<Panel {...gprops} />
|
|
137
|
+
</PluginBoundary>
|
|
138
|
+
</LayoutGroup>
|
|
139
|
+
</BreakoutSheet>
|
|
140
|
+
) : (
|
|
141
|
+
<ChromePopover onClose={onClose}>
|
|
142
|
+
<LayoutGroup id={`plugin-panel:${id}`}>
|
|
143
|
+
<PluginBoundary resetKey={base.snapshot}>
|
|
144
|
+
<Panel {...gprops} />
|
|
145
|
+
</PluginBoundary>
|
|
146
|
+
</LayoutGroup>
|
|
147
|
+
</ChromePopover>
|
|
148
|
+
))}
|
|
149
|
+
</AnimatePresence>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Wires the global plugin controls into the chrome (ADR 0038). Returns the rail
|
|
154
|
+
* triggers (pinned + custom on touch, all on desktop), the `⋮` menu rows for the rest
|
|
155
|
+
* (touch only, the rail can't overflow), and the centrally-hosted panels. One panel
|
|
156
|
+
* open at a time. */
|
|
157
|
+
export function usePluginChrome(): { rail: ReactNode; menuActions: PluginMenuAction[]; panels: ReactNode } {
|
|
158
|
+
const ctx = useLive();
|
|
159
|
+
const coarse = useCoarsePointer();
|
|
160
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
161
|
+
if (!ctx?.live) return { rail: null, menuActions: [], panels: null };
|
|
162
|
+
|
|
163
|
+
const entries = globalPlugins(ctx.plugins).filter((e) => e.def.client.global?.Panel || e.def.client.global?.Control);
|
|
164
|
+
// desktop: everything inline. touch: pinned (or a custom Control) inline, the rest → ⋮.
|
|
165
|
+
const inRail = (def: (typeof entries)[number]["def"]) => !coarse || !!def.client.global?.pinned || !!def.client.global?.Control;
|
|
166
|
+
const ctrl = (id: string): PanelController => ({
|
|
167
|
+
open: openId === id,
|
|
168
|
+
toggle: () => setOpenId((v) => (v === id ? null : id)),
|
|
169
|
+
close: () => setOpenId(null),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const rail = entries
|
|
173
|
+
.filter((e) => inRail(e.def))
|
|
174
|
+
.map(({ id, def }) => <RailTrigger key={id} ctx={ctx} id={id} def={def} panel={ctrl(id)} />);
|
|
175
|
+
|
|
176
|
+
const menuActions: PluginMenuAction[] = entries
|
|
177
|
+
.filter((e) => !inRail(e.def))
|
|
178
|
+
.map(({ id, def }) => ({ key: `plugin:${id}`, label: def.client.global?.label ?? id, icon: def.client.global?.icon ?? null, onClick: () => setOpenId(id) }));
|
|
179
|
+
|
|
180
|
+
const panels = entries.map(({ id, def }) => (
|
|
181
|
+
<PanelHost key={id} ctx={ctx} id={id} def={def} open={openId === id} onClose={() => setOpenId(null)} coarse={coarse} />
|
|
182
|
+
));
|
|
183
|
+
|
|
184
|
+
return { rail, menuActions, panels };
|
|
185
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginDef } from "@liebstoeckel/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
|
+
type Registry = Record<string, PluginDef<any>>;
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
export type GlobalEntry = { id: string; def: PluginDef<any> };
|
|
7
|
+
|
|
8
|
+
/** The registered plugins that expose a deck-wide `client.global` namespace, in
|
|
9
|
+
* registration order (the order the deck listed them in `plugins={[…]}`). The
|
|
10
|
+
* engine mounts one set of global surfaces per entry. See ADR 0021. */
|
|
11
|
+
export function globalPlugins(registry: Registry): GlobalEntry[] {
|
|
12
|
+
return Object.entries(registry)
|
|
13
|
+
.filter(([, def]) => def.client.global)
|
|
14
|
+
.map(([id, def]) => ({ id, def }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { detectLive, type LiveInfo } from "./detect";
|
|
2
|
+
export { getParticipantId } from "./participant";
|
|
3
|
+
export { connectLive, type LiveConnection } from "./connect";
|
|
4
|
+
export { getDeckIndex, setDeckIndex, useLiveDeck, type DeckController } from "./deckIndex";
|
|
5
|
+
export { mergeUi } from "./ui";
|
|
6
|
+
export { Plugin, LiveProvider, useLive, type LiveContextValue } from "./Plugin";
|
|
7
|
+
export { BreakoutAllowedContext } from "./breakout";
|