@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,454 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { MDXProvider } from "@mdx-js/react";
|
|
4
|
+
import { mdxComponents } from "@liebstoeckel/components";
|
|
5
|
+
import { useDeckSync } from "./useDeckSync";
|
|
6
|
+
import { useDeckNav, useTouchNav } from "./nav";
|
|
7
|
+
import { useLive } from "./live/Plugin";
|
|
8
|
+
import { PresenterPanel } from "./live/presenterPanel";
|
|
9
|
+
import { BreakoutAllowedContext } from "./live/breakout";
|
|
10
|
+
import { useLiveDeck } from "./live/deckIndex";
|
|
11
|
+
import { ScaledStage, SlideFrame } from "./Stage";
|
|
12
|
+
import { PresenterShare } from "./QrOverlay";
|
|
13
|
+
import { PersistentProvider } from "./PersistentLayer";
|
|
14
|
+
import { useCoarsePointer } from "./useCoarsePointer";
|
|
15
|
+
import { normalizeSlides } from "./slides";
|
|
16
|
+
import type { DeckProps } from "./Deck";
|
|
17
|
+
|
|
18
|
+
function useNow(ms = 1000) {
|
|
19
|
+
const [now, setNow] = useState(() => Date.now());
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const id = setInterval(() => setNow(Date.now()), ms);
|
|
22
|
+
return () => clearInterval(id);
|
|
23
|
+
}, [ms]);
|
|
24
|
+
return now;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type WakeLockNav = Navigator & { wakeLock?: { request(type: "screen"): Promise<{ release(): Promise<void> }> } };
|
|
28
|
+
|
|
29
|
+
/** Hold a screen Wake Lock while `on`, a phone used as a remote shouldn't sleep
|
|
30
|
+
* mid-talk. Best-effort (browser/permission dependent); re-acquires when the tab
|
|
31
|
+
* returns to the foreground (locks drop on hide). */
|
|
32
|
+
function useWakeLock(on: boolean) {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const nav = typeof navigator !== "undefined" ? (navigator as WakeLockNav) : undefined;
|
|
35
|
+
if (!on || !nav?.wakeLock) return;
|
|
36
|
+
let sentinel: { release(): Promise<void> } | null = null;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
const acquire = () =>
|
|
39
|
+
nav
|
|
40
|
+
.wakeLock!.request("screen")
|
|
41
|
+
.then((s) => {
|
|
42
|
+
if (cancelled) void s.release().catch(() => {});
|
|
43
|
+
else sentinel = s;
|
|
44
|
+
})
|
|
45
|
+
.catch(() => {});
|
|
46
|
+
void acquire();
|
|
47
|
+
const onVis = () => {
|
|
48
|
+
if (document.visibilityState === "visible") void acquire();
|
|
49
|
+
};
|
|
50
|
+
document.addEventListener("visibilitychange", onVis);
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true;
|
|
53
|
+
document.removeEventListener("visibilitychange", onVis);
|
|
54
|
+
void sentinel?.release().catch(() => {});
|
|
55
|
+
};
|
|
56
|
+
}, [on]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fmtElapsed = (delta: number) => {
|
|
60
|
+
const s = Math.max(0, Math.floor(delta / 1000));
|
|
61
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type TimerCell = { at: number; epoch: number };
|
|
65
|
+
|
|
66
|
+
/** The shared talk-timer start, the **conflict-free** way (ADR 0030): each presenter
|
|
67
|
+
* writes its OWN cell (keyed by `doc.clientID`) so two clients never contend for one
|
|
68
|
+
* key, no LWW tie-break, no timing race. The start is a deterministic reduce over
|
|
69
|
+
* the map: the MIN `at` within the highest `epoch`. Reset bumps the epoch (a higher
|
|
70
|
+
* epoch wins), so it propagates to every device. Each device computes elapsed itself
|
|
71
|
+
* (sub-second clock skew). Only drivers write; standalone → per-window fallback doc. */
|
|
72
|
+
function usePresenterStart(doc: Y.Doc, canWrite: boolean): { startedAt: number; reset: () => void } {
|
|
73
|
+
const map = useMemo(() => doc.getMap("timer") as Y.Map<TimerCell>, [doc]);
|
|
74
|
+
const key = String(doc.clientID);
|
|
75
|
+
|
|
76
|
+
const reduce = useCallback((): { start?: number; epoch: number } => {
|
|
77
|
+
let epoch = -1;
|
|
78
|
+
let start: number | undefined;
|
|
79
|
+
map.forEach((v) => {
|
|
80
|
+
if (!v || typeof v.at !== "number") return;
|
|
81
|
+
const e = typeof v.epoch === "number" ? v.epoch : 0;
|
|
82
|
+
if (e > epoch) {
|
|
83
|
+
epoch = e;
|
|
84
|
+
start = v.at;
|
|
85
|
+
} else if (e === epoch && v.at < (start ?? Infinity)) {
|
|
86
|
+
start = v.at;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return { start, epoch: Math.max(0, epoch) };
|
|
90
|
+
}, [map]);
|
|
91
|
+
|
|
92
|
+
const [startedAt, setStartedAt] = useState<number | undefined>(() => reduce().start);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const apply = () => setStartedAt(reduce().start);
|
|
96
|
+
map.observe(apply);
|
|
97
|
+
apply();
|
|
98
|
+
// claim our own cell at the current epoch, only we ever write this key
|
|
99
|
+
if (canWrite && !map.has(key)) map.set(key, { at: Date.now(), epoch: reduce().epoch });
|
|
100
|
+
return () => map.unobserve(apply);
|
|
101
|
+
}, [map, key, canWrite, reduce]);
|
|
102
|
+
|
|
103
|
+
const reset = useCallback(() => {
|
|
104
|
+
if (canWrite) map.set(key, { at: Date.now(), epoch: reduce().epoch + 1 });
|
|
105
|
+
}, [map, key, canWrite, reduce]);
|
|
106
|
+
|
|
107
|
+
return { startedAt: startedAt ?? Date.now(), reset };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Label({ children, dot }: { children: ReactNode; dot?: boolean }) {
|
|
111
|
+
return (
|
|
112
|
+
<div className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.3em] text-muted">
|
|
113
|
+
{dot && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary shadow-[0_0_8px_var(--brand-primary)]" />}
|
|
114
|
+
{children}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fills its parent box; ScaledStage letterboxes the 16:9 slide inside. The PARENT
|
|
120
|
+
// decides the size, so previews shrink to fit available height (never push the
|
|
121
|
+
// notes off-screen). Rendered live (not a thumbnail), the presenter's current/next
|
|
122
|
+
// previews are only two slides, so this stays cheap while staying pixel-crisp and
|
|
123
|
+
// reflecting live plugin state, unlike the static build-time thumbnails.
|
|
124
|
+
function Thumb({ Component, interactive = true }: { Component?: ComponentType; interactive?: boolean }) {
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
className={`relative h-full w-full overflow-hidden rounded-2xl border border-border bg-bg shadow-[0_20px_60px_-20px_rgba(0,0,0,0.8)] ${interactive ? "" : "pointer-events-none"}`}
|
|
128
|
+
>
|
|
129
|
+
<MDXProvider components={mdxComponents}>
|
|
130
|
+
<PersistentProvider>
|
|
131
|
+
<BreakoutAllowedContext.Provider value={false}>
|
|
132
|
+
<ScaledStage className="absolute inset-0">
|
|
133
|
+
<SlideFrame still>{Component ? <Component /> : null}</SlideFrame>
|
|
134
|
+
</ScaledStage>
|
|
135
|
+
</BreakoutAllowedContext.Provider>
|
|
136
|
+
</PersistentProvider>
|
|
137
|
+
</MDXProvider>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Prominent step/reveal progress, readable from a distance. Makes it obvious why
|
|
143
|
+
// the slide isn't advancing yet: there are still reveals left on this slide.
|
|
144
|
+
function StepIndicator({ step, total }: { step: number; total: number }) {
|
|
145
|
+
const revealing = step < total;
|
|
146
|
+
const remaining = total - step;
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
className={`flex shrink-0 flex-col gap-2 rounded-xl border px-4 py-3 transition-colors ${
|
|
150
|
+
revealing ? "border-accent/60 bg-accent/10" : "border-border bg-surface/30"
|
|
151
|
+
}`}
|
|
152
|
+
>
|
|
153
|
+
<div className="flex items-center justify-between gap-3">
|
|
154
|
+
<span
|
|
155
|
+
className={`flex items-center gap-2 font-mono text-sm font-semibold uppercase tracking-[0.18em] ${
|
|
156
|
+
revealing ? "text-accent" : "text-muted"
|
|
157
|
+
}`}
|
|
158
|
+
>
|
|
159
|
+
{revealing && <span className="h-2 w-2 animate-pulse rounded-full bg-accent shadow-[0_0_8px_var(--brand-accent)]" />}
|
|
160
|
+
{revealing ? "Revealing steps" : "All steps shown"}
|
|
161
|
+
</span>
|
|
162
|
+
<span className="font-mono text-2xl font-semibold tabular-nums text-text">
|
|
163
|
+
{step}
|
|
164
|
+
<span className="text-muted">/{total}</span>
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
{/* segmented bar, one segment per step, filled up to the current reveal */}
|
|
168
|
+
<div className="flex gap-1.5">
|
|
169
|
+
{Array.from({ length: total }).map((_, i) => (
|
|
170
|
+
<span
|
|
171
|
+
key={i}
|
|
172
|
+
className={`h-2.5 flex-1 rounded-full transition-colors ${i < step ? "bg-accent" : "bg-border"}`}
|
|
173
|
+
/>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
<span className="font-mono text-xs tracking-wide text-muted">
|
|
177
|
+
{revealing
|
|
178
|
+
? `${remaining} more ${remaining === 1 ? "reveal" : "reveals"}, then Next advances the slide`
|
|
179
|
+
: "Next advances to the following slide"}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function PresenterView({ slides, brands = ["default"], title = "liebstoeckel" }: DeckProps) {
|
|
186
|
+
const norm = useMemo(() => normalizeSlides(slides), [slides]);
|
|
187
|
+
// Same controller selection as the Deck: shared doc when live, else BroadcastChannel.
|
|
188
|
+
const liveCtx = useLive();
|
|
189
|
+
const live = !!liveCtx?.live;
|
|
190
|
+
const fallbackDoc = useMemo(() => new Y.Doc(), []);
|
|
191
|
+
const sync = useDeckSync(norm.length);
|
|
192
|
+
const liveDeck = useLiveDeck(liveCtx?.doc ?? fallbackDoc, norm.length, liveCtx?.role !== "viewer");
|
|
193
|
+
const ctrl = live ? liveDeck : sync;
|
|
194
|
+
const { index, step, total, setIndex, next, prev } = ctrl;
|
|
195
|
+
|
|
196
|
+
// Share both links (Q / the header button), live only. The viewer link is
|
|
197
|
+
// injected; the presenter link is this window's own URL minus the #presenter
|
|
198
|
+
// hash (it loaded with ?t=<presenterToken>), scanning it drives from a phone.
|
|
199
|
+
const [share, setShare] = useState(false);
|
|
200
|
+
const presenterUrl = useMemo(
|
|
201
|
+
() => (typeof location !== "undefined" ? location.origin + location.pathname + location.search : undefined),
|
|
202
|
+
[],
|
|
203
|
+
);
|
|
204
|
+
useDeckNav({ count: norm.length, setIndex, onNext: next, onPrev: prev, onQr: live ? () => setShare((v) => !v) : undefined });
|
|
205
|
+
useTouchNav({ enabled: true, onNext: next, onPrev: prev });
|
|
206
|
+
const now = useNow();
|
|
207
|
+
// Talk timer: the START is shared via the doc (conflict-free per-client cells,
|
|
208
|
+
// ADR 0030) so every presenter surface agrees; standalone falls back to the
|
|
209
|
+
// per-window doc. Only drivers write.
|
|
210
|
+
const timerDoc = liveCtx?.doc ?? fallbackDoc;
|
|
211
|
+
const canWriteTimer = !live || liveCtx?.role !== "viewer";
|
|
212
|
+
const { startedAt, reset: resetTimer } = usePresenterStart(timerDoc, canWriteTimer);
|
|
213
|
+
|
|
214
|
+
// Phone presenter (ADR 0027): a notes-first confidence monitor + remote. Keep the
|
|
215
|
+
// screen awake, and offer a way back to the audience deck (drop the #presenter
|
|
216
|
+
// hash → Present re-selects the Deck at mount).
|
|
217
|
+
const coarse = useCoarsePointer();
|
|
218
|
+
useWakeLock(coarse);
|
|
219
|
+
const backToSlides = () => {
|
|
220
|
+
if (typeof location !== "undefined") location.assign(location.pathname + location.search);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Focus mode (ADR 0032): full-bleed the active pane (notes / a plugin console),
|
|
224
|
+
// hiding the slide previews. Nav + timer + the tab strip stay pinned. `z` toggles,
|
|
225
|
+
// `Esc` restores; both guard against firing while typing in a console input.
|
|
226
|
+
const [focused, setFocused] = useState(false);
|
|
227
|
+
const toggleFocus = useCallback(() => setFocused((v) => !v), []);
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const onKey = (e: KeyboardEvent) => {
|
|
230
|
+
const el = document.activeElement as HTMLElement | null;
|
|
231
|
+
const editable = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable);
|
|
232
|
+
if (e.key === "Escape" && focused) {
|
|
233
|
+
setFocused(false);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (e.key === "z" && !editable) {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
toggleFocus();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
window.addEventListener("keydown", onKey);
|
|
242
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
243
|
+
}, [focused, toggleFocus]);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
document.body.dataset.brand = brands[0];
|
|
247
|
+
}, [brands]);
|
|
248
|
+
|
|
249
|
+
const Current = norm[index]?.Component;
|
|
250
|
+
const Next = norm[index + 1]?.Component;
|
|
251
|
+
const notes = norm[index]?.notes;
|
|
252
|
+
const wall = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
253
|
+
const elapsed = fmtElapsed(now - startedAt);
|
|
254
|
+
const count = norm.length;
|
|
255
|
+
// Nothing left to advance to: last slide AND all reveals shown (a remaining reveal
|
|
256
|
+
// still makes Next a "Reveal →"). Symmetric for Prev at the very start.
|
|
257
|
+
const atEnd = index >= count - 1 && step >= total;
|
|
258
|
+
const atStart = index <= 0 && step <= 0;
|
|
259
|
+
const noNotes = <span className="text-muted">, no notes for this slide, </span>;
|
|
260
|
+
const shareOverlay = (
|
|
261
|
+
<PresenterShare open={share} viewerUrl={liveCtx?.viewerUrl} presenterUrl={presenterUrl} onClose={() => setShare(false)} />
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// ── Mobile: notes-first stack (ADR 0027) ───────────────────────────────────
|
|
265
|
+
// Notes dominate; a compact next+reveal peek; big thumb-zone Prev/Next. Vertical
|
|
266
|
+
// scroll moves the notes, horizontal swipe (useTouchNav) changes slides, the two
|
|
267
|
+
// axes don't collide, so notes scroll can't misfire a slide change.
|
|
268
|
+
if (coarse) {
|
|
269
|
+
return (
|
|
270
|
+
<div className="flex h-dvh w-screen flex-col bg-bg font-body text-text">
|
|
271
|
+
{shareOverlay}
|
|
272
|
+
{/* 1 · slim status bar */}
|
|
273
|
+
<div
|
|
274
|
+
className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-2"
|
|
275
|
+
style={{ paddingTop: "calc(0.5rem + env(safe-area-inset-top))" }}
|
|
276
|
+
>
|
|
277
|
+
<button onClick={backToSlides} aria-label="Back to slides" className="font-mono text-xs uppercase tracking-wider text-muted transition active:text-text">
|
|
278
|
+
‹ slides
|
|
279
|
+
</button>
|
|
280
|
+
<span className="font-mono text-sm tabular-nums text-text">
|
|
281
|
+
{String(index + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
|
|
282
|
+
</span>
|
|
283
|
+
<div className="flex items-center gap-3">
|
|
284
|
+
<button onClick={resetTimer} aria-label="Reset timer" className="font-mono text-sm tabular-nums text-primary">
|
|
285
|
+
{elapsed}
|
|
286
|
+
</button>
|
|
287
|
+
{live && (
|
|
288
|
+
<button onClick={() => setShare((v) => !v)} aria-label="Share session links" className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-muted transition active:text-accent">
|
|
289
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
290
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
291
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
292
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
293
|
+
<path d="M14 14h3M20 14v3M14 20h3M20 20h.01M17 17v.01" />
|
|
294
|
+
</svg>
|
|
295
|
+
</button>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* 2 · dominant region: notes (default) + one tab per plugin console (ADR 0031),
|
|
301
|
+
with a maximize toggle (ADR 0032) */}
|
|
302
|
+
<PresenterPanel variant="mobile" notes={notes ?? noNotes} focused={focused} onToggleFocus={toggleFocus} />
|
|
303
|
+
|
|
304
|
+
{/* 3 · compact next + reveal peek, reclaimed by focus mode */}
|
|
305
|
+
{!focused && (
|
|
306
|
+
<div className="flex shrink-0 items-center gap-3 border-t border-border px-4 py-2">
|
|
307
|
+
<div className="h-12 w-[5.5rem] shrink-0 opacity-80">
|
|
308
|
+
{Next ? <Thumb Component={Next} interactive={false} /> : <div className="h-full w-full rounded-md border border-dashed border-border" />}
|
|
309
|
+
</div>
|
|
310
|
+
<div className="min-w-0 flex-1 font-mono text-[11px]">
|
|
311
|
+
<div className="uppercase tracking-[0.2em] text-muted">{Next ? "next up" : "end of deck"}</div>
|
|
312
|
+
{total > 0 && (
|
|
313
|
+
<div className={step < total ? "text-accent" : "text-muted"}>
|
|
314
|
+
{step < total ? `revealing ${step} / ${total}, Next reveals` : "Next → following slide"}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{/* 4 · thumb-zone controls */}
|
|
322
|
+
<div
|
|
323
|
+
className="flex shrink-0 items-stretch gap-3 px-4 pt-2"
|
|
324
|
+
style={{ paddingBottom: "calc(0.85rem + env(safe-area-inset-bottom))" }}
|
|
325
|
+
>
|
|
326
|
+
<button
|
|
327
|
+
onClick={prev}
|
|
328
|
+
disabled={atStart}
|
|
329
|
+
aria-label="Previous"
|
|
330
|
+
className="flex-1 rounded-xl border border-border py-4 font-mono text-lg text-muted transition active:border-text active:text-text disabled:opacity-40"
|
|
331
|
+
>
|
|
332
|
+
←
|
|
333
|
+
</button>
|
|
334
|
+
<button
|
|
335
|
+
onClick={next}
|
|
336
|
+
disabled={atEnd}
|
|
337
|
+
className="flex-[2.4] rounded-xl bg-primary py-4 font-mono text-base font-semibold uppercase tracking-widest text-on-primary transition active:brightness-110 disabled:opacity-40"
|
|
338
|
+
>
|
|
339
|
+
{step < total ? "Reveal →" : "Next →"}
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Desktop: two-column confidence monitor ──────────────────────────────────
|
|
347
|
+
// Prev/Next, reused in the current column (normal) and pinned under the panel
|
|
348
|
+
// (focus mode) so navigation is never hidden (ADR 0032).
|
|
349
|
+
const navRow = (
|
|
350
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
351
|
+
<button
|
|
352
|
+
onClick={prev}
|
|
353
|
+
disabled={atStart}
|
|
354
|
+
className="flex-1 rounded-xl border border-border py-3 font-mono text-sm uppercase tracking-widest text-muted transition hover:border-text hover:text-text disabled:opacity-40 disabled:hover:border-border disabled:hover:text-muted"
|
|
355
|
+
>
|
|
356
|
+
← Prev
|
|
357
|
+
</button>
|
|
358
|
+
<button
|
|
359
|
+
onClick={next}
|
|
360
|
+
disabled={atEnd}
|
|
361
|
+
className="flex-[2] rounded-xl bg-primary py-3 font-mono text-sm font-semibold uppercase tracking-widest text-on-primary transition hover:brightness-110 disabled:opacity-40 disabled:hover:brightness-100"
|
|
362
|
+
>
|
|
363
|
+
{step < total ? "Reveal →" : "Next →"}
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
return (
|
|
368
|
+
<div className="flex h-screen w-screen flex-col bg-bg font-body text-text">
|
|
369
|
+
{/* top bar */}
|
|
370
|
+
<header className="flex items-center justify-between border-b border-border px-4 py-3 lg:px-8 lg:py-4">
|
|
371
|
+
<div className="flex items-baseline gap-3">
|
|
372
|
+
<span className="font-heading text-xl font-semibold tracking-tight text-text lg:text-2xl">{title}</span>
|
|
373
|
+
<span className="hidden font-mono text-[11px] uppercase tracking-[0.3em] text-muted sm:inline">presenter</span>
|
|
374
|
+
</div>
|
|
375
|
+
<div className="flex items-center gap-4 lg:gap-8">
|
|
376
|
+
<div className="hidden text-right sm:block">
|
|
377
|
+
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-muted">clock</div>
|
|
378
|
+
<div className="font-mono text-lg tabular-nums text-text">{wall}</div>
|
|
379
|
+
</div>
|
|
380
|
+
<div className="text-right">
|
|
381
|
+
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-muted">elapsed</div>
|
|
382
|
+
<div className="font-mono text-2xl font-medium tabular-nums text-primary lg:text-3xl">{fmtElapsed(now - startedAt)}</div>
|
|
383
|
+
</div>
|
|
384
|
+
<button
|
|
385
|
+
onClick={resetTimer}
|
|
386
|
+
className="rounded-lg border border-border px-3 py-2 font-mono text-xs uppercase tracking-wider text-muted transition hover:border-primary hover:text-primary"
|
|
387
|
+
>
|
|
388
|
+
reset
|
|
389
|
+
</button>
|
|
390
|
+
{live && (
|
|
391
|
+
<button
|
|
392
|
+
onClick={() => setShare((v) => !v)}
|
|
393
|
+
title="Share links (Q), viewer + presenter QR"
|
|
394
|
+
aria-label="Share session links"
|
|
395
|
+
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border text-muted transition hover:border-accent hover:text-accent"
|
|
396
|
+
>
|
|
397
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
398
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
399
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
400
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
401
|
+
<path d="M14 14h3M20 14v3M14 20h3M20 20h.01M17 17v.01" />
|
|
402
|
+
</svg>
|
|
403
|
+
</button>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
</header>
|
|
407
|
+
|
|
408
|
+
{shareOverlay}
|
|
409
|
+
|
|
410
|
+
{/* main: flex row; each column is a min-h-0 flex-col so inner regions can
|
|
411
|
+
shrink. Thumbnails get capped/flexible heights; the notes panel always
|
|
412
|
+
keeps a guaranteed, scrollable minimum. */}
|
|
413
|
+
<div className="flex min-h-0 flex-1 flex-col gap-5 p-5 lg:flex-row lg:gap-7 lg:p-7">
|
|
414
|
+
{/* current, hidden in focus mode (it's on the room's screen anyway) */}
|
|
415
|
+
{!focused && (
|
|
416
|
+
<section className="flex min-h-0 min-w-0 flex-col gap-3 lg:flex-[1.55]">
|
|
417
|
+
<Label dot>
|
|
418
|
+
On screen · {String(index + 1).padStart(2, "0")} / {String(norm.length).padStart(2, "0")}
|
|
419
|
+
</Label>
|
|
420
|
+
<div className="h-[30vh] min-h-0 min-w-0 lg:h-auto lg:flex-1">
|
|
421
|
+
<Thumb Component={Current} />
|
|
422
|
+
</div>
|
|
423
|
+
{total > 0 && <StepIndicator step={step} total={total} />}
|
|
424
|
+
{navRow}
|
|
425
|
+
</section>
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{/* next + notes (full width in focus mode) */}
|
|
429
|
+
<aside className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
|
430
|
+
{/* next preview hidden on phones to give notes the room, and in focus mode.
|
|
431
|
+
The height share (basis) sits on THIS wrapper, a flex child of the aside,
|
|
432
|
+
which has a definite height, so the inner preview can be flex-1 and fill
|
|
433
|
+
it. (A % basis on an auto-height parent doesn't resolve, which collapsed
|
|
434
|
+
the box to its min-height and made the preview render tiny.) */}
|
|
435
|
+
{!focused && (
|
|
436
|
+
<div className="hidden min-h-[120px] shrink basis-[34%] flex-col gap-3 lg:flex">
|
|
437
|
+
<Label>{Next ? "Next up" : "End of deck"}</Label>
|
|
438
|
+
<div className="min-h-0 min-w-0 flex-1 opacity-80">
|
|
439
|
+
{Next ? <Thumb Component={Next} interactive={false} /> : <div className="h-full w-full rounded-2xl border border-dashed border-border" />}
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
|
|
444
|
+
{/* notes (default tab) + one tab per plugin console (ADR 0031), with the
|
|
445
|
+
maximize toggle (ADR 0032). */}
|
|
446
|
+
<PresenterPanel variant="desktop" notes={notes ?? noNotes} focused={focused} onToggleFocus={toggleFocus} />
|
|
447
|
+
{/* pin nav under the panel when the current column (which normally holds it)
|
|
448
|
+
is hidden by focus mode */}
|
|
449
|
+
{focused && navRow}
|
|
450
|
+
</aside>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { MDXProvider } from "@mdx-js/react";
|
|
4
|
+
import { mdxComponents } from "@liebstoeckel/components";
|
|
5
|
+
import { useTheme } from "@liebstoeckel/plugin-ui";
|
|
6
|
+
import type { PluginDef } from "@liebstoeckel/plugin-sdk";
|
|
7
|
+
import { STAGE_H, STAGE_W, SlideFrame } from "./Stage";
|
|
8
|
+
import { PersistentProvider } from "./PersistentLayer";
|
|
9
|
+
import { StepsProvider } from "./steps";
|
|
10
|
+
import { LiveProvider, type LiveContextValue } from "./live/Plugin";
|
|
11
|
+
import { normalizeSlides } from "./slides";
|
|
12
|
+
import type { DeckProps } from "./Deck";
|
|
13
|
+
import {
|
|
14
|
+
PRINT_READY,
|
|
15
|
+
PRINT_SELECT_EVENT,
|
|
16
|
+
SLIDE_COUNT,
|
|
17
|
+
printRequest,
|
|
18
|
+
type PrintSelect,
|
|
19
|
+
} from "./build/capture-protocol";
|
|
20
|
+
|
|
21
|
+
// Past any real slide's step count, so every <Step> is revealed for a complete,
|
|
22
|
+
// final-state page (reveals start at target, <Step> uses initial={false}).
|
|
23
|
+
const ALL_STEPS = 1e6;
|
|
24
|
+
|
|
25
|
+
/** Build-time **print** render (vector PDF export): every selected slide laid out
|
|
26
|
+
* at the native 1280×720 canvas, each forced onto its own print page, so a single
|
|
27
|
+
* headless `page.pdf()` produces a multi-page, text-preserving PDF. No nav, no
|
|
28
|
+
* AnimatePresence, no scaling, final state only. Driven via the print protocol:
|
|
29
|
+
* publishes SLIDE_COUNT, renders the indices it's handed (PRINT_SELECT_EVENT), and
|
|
30
|
+
* echoes the select token into PRINT_READY once that selection has painted.
|
|
31
|
+
*
|
|
32
|
+
* Each slide gets its own PersistentProvider: persistent travel (ADR 0007) is a
|
|
33
|
+
* live-nav concept, so in a static all-slides layout each slide just renders its
|
|
34
|
+
* own elements in final position. */
|
|
35
|
+
export function PrintView({ slides, brands = ["default"], plugins = [] }: DeckProps) {
|
|
36
|
+
const norm = useMemo(() => normalizeSlides(slides), [slides]);
|
|
37
|
+
|
|
38
|
+
// Offline (live:false) plugin context → each <Plugin> renders its fallback, same
|
|
39
|
+
// as opening the standalone .html (mirrors CaptureView).
|
|
40
|
+
const theme = useTheme();
|
|
41
|
+
const doc = useMemo(() => new Y.Doc(), []);
|
|
42
|
+
const liveValue = useMemo<LiveContextValue>(
|
|
43
|
+
() => ({
|
|
44
|
+
live: false,
|
|
45
|
+
role: "presenter",
|
|
46
|
+
participant: "print",
|
|
47
|
+
doc,
|
|
48
|
+
theme,
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
plugins: Object.fromEntries(plugins.map((p) => [p.id, p])) as Record<string, PluginDef<any>>,
|
|
51
|
+
}),
|
|
52
|
+
[doc, theme, plugins],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// The slides to lay out, and the token to echo once they've painted. The driver
|
|
56
|
+
// sends a PRINT_SELECT once it knows the count; until then, honor a flag-supplied
|
|
57
|
+
// list (or all slides) so a standalone print also works.
|
|
58
|
+
const initial = useMemo(() => printRequest()?.indices, []);
|
|
59
|
+
const [sel, setSel] = useState<PrintSelect>(() => ({
|
|
60
|
+
indices: initial && initial.length ? initial : norm.map((_, i) => i),
|
|
61
|
+
token: 0,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
document.body.dataset.brand = brands[0];
|
|
66
|
+
}, [brands]);
|
|
67
|
+
|
|
68
|
+
// Publish the slide count and follow the driver's selection.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const g = globalThis as Record<string, unknown>;
|
|
71
|
+
g[SLIDE_COUNT] = norm.length;
|
|
72
|
+
const onSelect = (e: Event) => {
|
|
73
|
+
const detail = (e as CustomEvent<PrintSelect>).detail;
|
|
74
|
+
if (detail && Array.isArray(detail.indices)) setSel(detail);
|
|
75
|
+
};
|
|
76
|
+
window.addEventListener(PRINT_SELECT_EVENT, onSelect as EventListener);
|
|
77
|
+
return () => window.removeEventListener(PRINT_SELECT_EVENT, onSelect as EventListener);
|
|
78
|
+
}, [norm.length]);
|
|
79
|
+
|
|
80
|
+
const chosen = useMemo(
|
|
81
|
+
() => sel.indices.filter((i) => Number.isInteger(i) && i >= 0 && i < norm.length),
|
|
82
|
+
[sel.indices, norm.length],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Signal "this selection has painted" after two animation frames + the deck's
|
|
86
|
+
// fonts, so the exporter prints a settled document. Reset first so a stale token
|
|
87
|
+
// is never read.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const g = globalThis as Record<string, unknown>;
|
|
90
|
+
g[PRINT_READY] = -1;
|
|
91
|
+
let raf1 = 0;
|
|
92
|
+
let raf2 = 0;
|
|
93
|
+
const fonts = (document as unknown as { fonts?: { ready?: Promise<unknown> } }).fonts?.ready;
|
|
94
|
+
Promise.resolve(fonts).then(() => {
|
|
95
|
+
raf1 = requestAnimationFrame(() => {
|
|
96
|
+
raf2 = requestAnimationFrame(() => {
|
|
97
|
+
g[PRINT_READY] = sel.token;
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
return () => {
|
|
102
|
+
cancelAnimationFrame(raf1);
|
|
103
|
+
cancelAnimationFrame(raf2);
|
|
104
|
+
};
|
|
105
|
+
}, [sel.token, chosen.length]);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<LiveProvider value={liveValue}>
|
|
109
|
+
<MDXProvider components={mdxComponents}>
|
|
110
|
+
{/* one page per slide; exact-size, clipped, page-broken. white page gutter
|
|
111
|
+
never shows because each block fills the page (margin:0 in page.pdf). */}
|
|
112
|
+
<style>{`@page { size: ${STAGE_W}px ${STAGE_H}px; margin: 0 }
|
|
113
|
+
/* The deck is fullscreen (theme sets html,body{height:100%;overflow:hidden}). For
|
|
114
|
+
print we must let the document GROW so the stacked slides paginate, otherwise it
|
|
115
|
+
clips to one viewport and only slide 1 prints. */
|
|
116
|
+
html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; margin: 0; padding: 0; background: #fff }
|
|
117
|
+
#root { height: auto !important; overflow: visible !important; position: static !important }
|
|
118
|
+
[data-print-page] { break-inside: avoid }
|
|
119
|
+
/* The film-grain feTurbulence rasterizes to a ~20MB full-page bitmap per slide in
|
|
120
|
+
print, invisible noise, enormous cost. Drop it; charts/gradients stay vector. */
|
|
121
|
+
[data-atmosphere-grain] { display: none !important }`}</style>
|
|
122
|
+
<div data-print-root>
|
|
123
|
+
{chosen.map((idx, n) => {
|
|
124
|
+
const Current = norm[idx]?.Component ?? (() => null);
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
key={idx}
|
|
128
|
+
data-print-page
|
|
129
|
+
style={{
|
|
130
|
+
position: "relative",
|
|
131
|
+
width: STAGE_W,
|
|
132
|
+
height: STAGE_H,
|
|
133
|
+
overflow: "hidden",
|
|
134
|
+
breakAfter: n < chosen.length - 1 ? "page" : "auto",
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<PersistentProvider>
|
|
138
|
+
<SlideFrame still>
|
|
139
|
+
<StepsProvider step={ALL_STEPS} slideIndex={idx}>
|
|
140
|
+
<Current />
|
|
141
|
+
</StepsProvider>
|
|
142
|
+
</SlideFrame>
|
|
143
|
+
</PersistentProvider>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
</MDXProvider>
|
|
149
|
+
</LiveProvider>
|
|
150
|
+
);
|
|
151
|
+
}
|