@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,156 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
3
|
+
import type { Role } from "@liebstoeckel/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
type Shortcut = { keys: string[]; label: string; presenterOnly?: boolean };
|
|
6
|
+
|
|
7
|
+
/** Guide for recovering + editing a built deck (linked from the eject footer). */
|
|
8
|
+
const EDIT_DOCS_URL = "https://docs.liebstoeckel.app/guides/editing-a-built-deck/";
|
|
9
|
+
|
|
10
|
+
function Kbd({ children, dim }: { children: string; dim?: boolean }) {
|
|
11
|
+
return (
|
|
12
|
+
<kbd
|
|
13
|
+
className={`inline-flex min-w-[1.9rem] items-center justify-center rounded-md border px-2 py-1 font-mono text-sm shadow-sm ${
|
|
14
|
+
dim ? "border-border/50 bg-bg/40 text-muted" : "border-border bg-bg text-text"
|
|
15
|
+
}`}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</kbd>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function HelpOverlay({
|
|
23
|
+
open,
|
|
24
|
+
onClose,
|
|
25
|
+
showBrand,
|
|
26
|
+
role,
|
|
27
|
+
ejectable,
|
|
28
|
+
}: {
|
|
29
|
+
open: boolean;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
showBrand?: boolean;
|
|
32
|
+
/** live role; undefined = standalone (everything enabled) */
|
|
33
|
+
role?: Role;
|
|
34
|
+
/** this `.html` carries its own recoverable source → show the "edit this deck" hint */
|
|
35
|
+
ejectable?: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!open) return;
|
|
39
|
+
const onKey = (e: KeyboardEvent) => {
|
|
40
|
+
if (e.key === "Escape") onClose();
|
|
41
|
+
};
|
|
42
|
+
window.addEventListener("keydown", onKey);
|
|
43
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
44
|
+
}, [open, onClose]);
|
|
45
|
+
|
|
46
|
+
const isViewer = role === "viewer";
|
|
47
|
+
|
|
48
|
+
const shortcuts: Shortcut[] = [
|
|
49
|
+
{ keys: ["→", "Space"], label: "Next / reveal step", presenterOnly: true },
|
|
50
|
+
{ keys: ["←"], label: "Previous / hide step", presenterOnly: true },
|
|
51
|
+
{ keys: ["Home", "End"], label: "First / last slide", presenterOnly: true },
|
|
52
|
+
{ keys: ["0-9", "↵"], label: "Jump to slide", presenterOnly: true },
|
|
53
|
+
{ keys: ["O"], label: "Overview grid", presenterOnly: true },
|
|
54
|
+
{ keys: ["F"], label: "Fullscreen" },
|
|
55
|
+
{ keys: ["B"], label: "Blur screen" },
|
|
56
|
+
{ keys: ["Q"], label: "Show join QR" },
|
|
57
|
+
{ keys: ["P"], label: "Open presenter view", presenterOnly: true },
|
|
58
|
+
...(showBrand ? [{ keys: ["T"], label: "Switch brand" }] : []),
|
|
59
|
+
{ keys: ["?"], label: "Toggle this help" },
|
|
60
|
+
{ keys: ["Esc"], label: "Close" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<AnimatePresence>
|
|
65
|
+
{open && (
|
|
66
|
+
<motion.div
|
|
67
|
+
className="absolute inset-0 z-50 flex items-center justify-center"
|
|
68
|
+
initial={{ opacity: 0 }}
|
|
69
|
+
animate={{ opacity: 1 }}
|
|
70
|
+
exit={{ opacity: 0 }}
|
|
71
|
+
transition={{ duration: 0.18 }}
|
|
72
|
+
>
|
|
73
|
+
<div
|
|
74
|
+
className="absolute inset-0 bg-bg/70 backdrop-blur-md"
|
|
75
|
+
onClick={onClose}
|
|
76
|
+
onContextMenu={(e) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
onClose();
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
<motion.div
|
|
82
|
+
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
|
83
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
84
|
+
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
85
|
+
transition={{ type: "spring", stiffness: 260, damping: 24 }}
|
|
86
|
+
className="relative w-[440px] rounded-2xl border border-border bg-surface/90 p-7 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
|
|
87
|
+
>
|
|
88
|
+
<div className="mb-5 flex items-baseline justify-between">
|
|
89
|
+
<span className="font-heading text-2xl font-semibold text-text">Shortcuts</span>
|
|
90
|
+
{role ? (
|
|
91
|
+
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-accent">
|
|
92
|
+
● live · {role}
|
|
93
|
+
</span>
|
|
94
|
+
) : (
|
|
95
|
+
<span className="font-mono text-[11px] uppercase tracking-[0.3em] text-muted">keyboard</span>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
{isViewer && (
|
|
99
|
+
<p className="mb-4 font-body text-sm text-muted">
|
|
100
|
+
You're following as a <span className="text-text">viewer</span>, the presenter drives
|
|
101
|
+
navigation. You can still interact (e.g. vote).
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
<ul className="space-y-3">
|
|
105
|
+
{shortcuts.map((s) => {
|
|
106
|
+
const disabled = isViewer && s.presenterOnly;
|
|
107
|
+
return (
|
|
108
|
+
<li
|
|
109
|
+
key={s.label}
|
|
110
|
+
className={`flex items-center justify-between gap-4 ${disabled ? "opacity-40" : ""}`}
|
|
111
|
+
title={disabled ? "Presenter only" : undefined}
|
|
112
|
+
>
|
|
113
|
+
<span className="font-body text-lg text-text/85">
|
|
114
|
+
{s.label}
|
|
115
|
+
{disabled && <span className="ml-2 font-mono text-[10px] uppercase tracking-wider text-muted">presenter</span>}
|
|
116
|
+
</span>
|
|
117
|
+
<span className="flex shrink-0 items-center gap-1.5">
|
|
118
|
+
{s.keys.map((k, i) => (
|
|
119
|
+
<span key={k} className="flex items-center gap-1.5">
|
|
120
|
+
{i > 0 && <span className="font-mono text-xs text-muted">/</span>}
|
|
121
|
+
<Kbd dim={disabled}>{k}</Kbd>
|
|
122
|
+
</span>
|
|
123
|
+
))}
|
|
124
|
+
</span>
|
|
125
|
+
</li>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</ul>
|
|
129
|
+
{ejectable && (
|
|
130
|
+
<div className="mt-6 border-t border-border pt-4">
|
|
131
|
+
<p className="font-body text-sm text-text/80">
|
|
132
|
+
This deck embeds its own source, recover it with{" "}
|
|
133
|
+
<code className="rounded bg-bg/60 px-1.5 py-0.5 font-mono text-[13px] text-text">
|
|
134
|
+
liebstoeckel eject
|
|
135
|
+
</code>{" "}
|
|
136
|
+
to edit it by hand or with an agent.{" "}
|
|
137
|
+
<a
|
|
138
|
+
href={EDIT_DOCS_URL}
|
|
139
|
+
target="_blank"
|
|
140
|
+
rel="noreferrer"
|
|
141
|
+
className="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
|
|
142
|
+
>
|
|
143
|
+
Learn how →
|
|
144
|
+
</a>
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
<div className="mt-6 border-t border-border pt-4 text-center font-mono text-[11px] uppercase tracking-[0.25em] text-muted">
|
|
149
|
+
right-click anywhere to toggle
|
|
150
|
+
</div>
|
|
151
|
+
</motion.div>
|
|
152
|
+
</motion.div>
|
|
153
|
+
)}
|
|
154
|
+
</AnimatePresence>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
4
|
+
|
|
5
|
+
/** A brief, dismissable "rotate for a bigger view" toast shown on a portrait phone
|
|
6
|
+
* (coarse pointer). Portaled to <body> so it renders at device scale, not inside
|
|
7
|
+
* the scaled canvas. Auto-hides; tap to dismiss. */
|
|
8
|
+
export function PortraitHint() {
|
|
9
|
+
const [portrait, setPortrait] = useState(false);
|
|
10
|
+
const [dismissed, setDismissed] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (typeof matchMedia !== "function") return;
|
|
14
|
+
const coarse = matchMedia("(pointer: coarse)");
|
|
15
|
+
const orient = matchMedia("(orientation: portrait)");
|
|
16
|
+
const update = () => setPortrait(coarse.matches && orient.matches);
|
|
17
|
+
update();
|
|
18
|
+
coarse.addEventListener?.("change", update);
|
|
19
|
+
orient.addEventListener?.("change", update);
|
|
20
|
+
return () => {
|
|
21
|
+
coarse.removeEventListener?.("change", update);
|
|
22
|
+
orient.removeEventListener?.("change", update);
|
|
23
|
+
};
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!portrait) return;
|
|
28
|
+
const t = setTimeout(() => setDismissed(true), 6000);
|
|
29
|
+
return () => clearTimeout(t);
|
|
30
|
+
}, [portrait]);
|
|
31
|
+
|
|
32
|
+
if (typeof document === "undefined") return null;
|
|
33
|
+
const show = portrait && !dismissed;
|
|
34
|
+
|
|
35
|
+
return createPortal(
|
|
36
|
+
<AnimatePresence>
|
|
37
|
+
{show && (
|
|
38
|
+
<motion.button
|
|
39
|
+
onClick={() => setDismissed(true)}
|
|
40
|
+
initial={{ opacity: 0, y: 12 }}
|
|
41
|
+
animate={{ opacity: 1, y: 0 }}
|
|
42
|
+
exit={{ opacity: 0, y: 12 }}
|
|
43
|
+
style={{
|
|
44
|
+
position: "fixed",
|
|
45
|
+
left: "50%",
|
|
46
|
+
bottom: "calc(1.25rem + env(safe-area-inset-bottom))",
|
|
47
|
+
transform: "translateX(-50%)",
|
|
48
|
+
zIndex: 9998,
|
|
49
|
+
display: "flex",
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
gap: "0.5rem",
|
|
52
|
+
padding: "0.55rem 0.95rem",
|
|
53
|
+
borderRadius: "999px",
|
|
54
|
+
border: "1px solid var(--brand-border, color-mix(in srgb, var(--brand-text, #e9e6d7) 14%, transparent))",
|
|
55
|
+
background: "color-mix(in srgb, var(--brand-surface, #1a2014) 88%, transparent)",
|
|
56
|
+
color: "var(--brand-muted, #9da28c)",
|
|
57
|
+
fontFamily: "var(--brand-font-mono, monospace)",
|
|
58
|
+
fontSize: "0.72rem",
|
|
59
|
+
letterSpacing: "0.04em",
|
|
60
|
+
backdropFilter: "blur(8px)",
|
|
61
|
+
cursor: "pointer",
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<span aria-hidden style={{ fontSize: "0.9rem" }}>⤾</span>
|
|
65
|
+
Rotate for a bigger view
|
|
66
|
+
</motion.button>
|
|
67
|
+
)}
|
|
68
|
+
</AnimatePresence>,
|
|
69
|
+
document.body,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { motion } from "motion/react";
|
|
11
|
+
import { StageScaleContext } from "./Stage";
|
|
12
|
+
import { SlideIndexContext } from "./steps";
|
|
13
|
+
|
|
14
|
+
export type PersistentItem = { id: string; render: () => ReactNode };
|
|
15
|
+
type Rect = { top: number; left: number; width: number; height: number };
|
|
16
|
+
|
|
17
|
+
// Per element id, the measured slot rect for each slide index that mounts one.
|
|
18
|
+
type SlotMap = Record<string, Record<number, Rect>>;
|
|
19
|
+
|
|
20
|
+
type Ctx = {
|
|
21
|
+
set: (id: string, slide: number, rect: Rect) => void;
|
|
22
|
+
clear: (id: string, slide: number) => void;
|
|
23
|
+
slots: SlotMap;
|
|
24
|
+
};
|
|
25
|
+
const PersistentCtx = createContext<Ctx | null>(null);
|
|
26
|
+
const usePersistent = () => {
|
|
27
|
+
const c = useContext(PersistentCtx);
|
|
28
|
+
if (!c) throw new Error("Slot/PersistentLayer must be used inside <PersistentProvider>");
|
|
29
|
+
return c;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const same = (a: Rect | undefined, b: Rect) =>
|
|
33
|
+
a && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;
|
|
34
|
+
|
|
35
|
+
/** Holds each persistent element's slot rect **per slide index**. Visibility is
|
|
36
|
+
* decided by the layer from the *current* slide index (not by ref-counting mounted
|
|
37
|
+
* slots), so when navigating to a slide that has no slot, the element hides at the
|
|
38
|
+
* index change, in step with the transition, instead of lingering until the old
|
|
39
|
+
* slide finishes its exit. When the incoming slide also has a slot, its rect is
|
|
40
|
+
* present for the new index and the element travels to it. */
|
|
41
|
+
export function PersistentProvider({ children }: { children: ReactNode }) {
|
|
42
|
+
const [slots, setSlots] = useState<SlotMap>({});
|
|
43
|
+
|
|
44
|
+
const set = useCallback((id: string, slide: number, rect: Rect) => {
|
|
45
|
+
setSlots((p) => {
|
|
46
|
+
if (same(p[id]?.[slide], rect)) return p;
|
|
47
|
+
return { ...p, [id]: { ...(p[id] ?? {}), [slide]: rect } };
|
|
48
|
+
});
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const clear = useCallback((id: string, slide: number) => {
|
|
52
|
+
setSlots((p) => {
|
|
53
|
+
if (!p[id] || !(slide in p[id]!)) return p;
|
|
54
|
+
const next = { ...p[id] };
|
|
55
|
+
delete next[slide];
|
|
56
|
+
return { ...p, [id]: next };
|
|
57
|
+
});
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
return <PersistentCtx.Provider value={{ set, clear, slots }}>{children}</PersistentCtx.Provider>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reserves space inside a slide and reports its box (per slide index) to the
|
|
64
|
+
* persistent layer. Drop the same `id` on multiple slides to make the element
|
|
65
|
+
* travel between them. */
|
|
66
|
+
export function Slot({ id, className }: { id: string; className?: string }) {
|
|
67
|
+
const { set, clear } = usePersistent();
|
|
68
|
+
const slide = useContext(SlideIndexContext);
|
|
69
|
+
const scale = useContext(StageScaleContext);
|
|
70
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
71
|
+
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
const el = ref.current!;
|
|
74
|
+
const root = (el.closest("[data-deck-root]") as HTMLElement) ?? document.body;
|
|
75
|
+
// getBoundingClientRect is post-transform (device px), but the persistent layer
|
|
76
|
+
// positions inside the stage's LOGICAL 1280×720 space, divide the stage scale
|
|
77
|
+
// back out, else the element is mis-placed whenever the stage isn't at 1:1.
|
|
78
|
+
const s = scale || 1;
|
|
79
|
+
const measure = (): Rect => {
|
|
80
|
+
const r = el.getBoundingClientRect();
|
|
81
|
+
const base = root.getBoundingClientRect();
|
|
82
|
+
return { top: (r.top - base.top) / s, left: (r.left - base.left) / s, width: r.width / s, height: r.height / s };
|
|
83
|
+
};
|
|
84
|
+
set(id, slide, measure());
|
|
85
|
+
const ro = new ResizeObserver(() => set(id, slide, measure()));
|
|
86
|
+
ro.observe(el);
|
|
87
|
+
ro.observe(root);
|
|
88
|
+
const onResize = () => set(id, slide, measure());
|
|
89
|
+
window.addEventListener("resize", onResize);
|
|
90
|
+
return () => {
|
|
91
|
+
ro.disconnect();
|
|
92
|
+
window.removeEventListener("resize", onResize);
|
|
93
|
+
clear(id, slide);
|
|
94
|
+
};
|
|
95
|
+
}, [id, slide, set, clear, scale]);
|
|
96
|
+
|
|
97
|
+
return <div ref={ref} data-slot={id} className={className} />;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SPRING = { type: "spring", stiffness: 200, damping: 26 } as const;
|
|
101
|
+
// Appear: snap the box into place (no positional tween) and only fade opacity in,
|
|
102
|
+
// so the element doesn't fly in from a previous slot's position or the origin.
|
|
103
|
+
const APPEAR = {
|
|
104
|
+
top: { duration: 0 },
|
|
105
|
+
left: { duration: 0 },
|
|
106
|
+
width: { duration: 0 },
|
|
107
|
+
height: { duration: 0 },
|
|
108
|
+
opacity: SPRING,
|
|
109
|
+
} as const;
|
|
110
|
+
|
|
111
|
+
/** Renders each persistent element ONCE (never unmounts → internal state kept),
|
|
112
|
+
* positioning it onto the slot on the **current** slide. Animates the box props
|
|
113
|
+
* (top/left/width/height) directly rather than Motion `layout` FLIP, whose
|
|
114
|
+
* projection mis-positions under the stage's scaled ancestor. Coordinates are the
|
|
115
|
+
* slot's LOGICAL rect. When the current slide has no slot the element fades out in
|
|
116
|
+
* place. The positional spring runs only for consecutive slot→slot travel; an
|
|
117
|
+
* appearance (first show, or coming from a slot-less slide) snaps + fades in.
|
|
118
|
+
* `currentIndex` is omitted only for standalone use (then any slot shows). */
|
|
119
|
+
export function PersistentLayer({ items, currentIndex }: { items: PersistentItem[]; currentIndex?: number }) {
|
|
120
|
+
const { slots } = usePersistent();
|
|
121
|
+
const last = useRef<Record<string, Rect>>({});
|
|
122
|
+
|
|
123
|
+
// Track the index we navigated FROM. Updated in render but ONLY when the index
|
|
124
|
+
// actually changes, so the transient re-renders that land the incoming slot's
|
|
125
|
+
// rect (same currentIndex) keep reporting the real previous slide. Robust against
|
|
126
|
+
// effect-flush timing, unlike a useEffect-updated ref.
|
|
127
|
+
const nav = useRef<{ current?: number; from?: number }>({ current: currentIndex, from: currentIndex });
|
|
128
|
+
if (nav.current.current !== currentIndex) {
|
|
129
|
+
nav.current = { current: currentIndex, from: nav.current.current };
|
|
130
|
+
}
|
|
131
|
+
const cameFrom = nav.current.from;
|
|
132
|
+
|
|
133
|
+
const resolved = items.map((it) => {
|
|
134
|
+
const perSlide = slots[it.id];
|
|
135
|
+
const active = perSlide ? (currentIndex != null ? perSlide[currentIndex] : Object.values(perSlide)[0]) : undefined;
|
|
136
|
+
if (active) last.current[it.id] = active;
|
|
137
|
+
const r = active ?? last.current[it.id];
|
|
138
|
+
const show = !!active;
|
|
139
|
+
// Travel only if the slide we came FROM also had this slot; otherwise (first
|
|
140
|
+
// show, or from a slot-less slide) snap + fade in instead of flying across.
|
|
141
|
+
const cameFromSlot = perSlide != null && cameFrom != null && perSlide[cameFrom] != null;
|
|
142
|
+
const appearing = show && !cameFromSlot;
|
|
143
|
+
return { it, r, show, appearing };
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="pointer-events-none absolute inset-0">
|
|
148
|
+
{resolved.map(({ it, r, show, appearing }) => (
|
|
149
|
+
<motion.div
|
|
150
|
+
key={it.id}
|
|
151
|
+
data-persistent={it.id}
|
|
152
|
+
initial={false}
|
|
153
|
+
animate={{
|
|
154
|
+
top: r?.top ?? 0,
|
|
155
|
+
left: r?.left ?? 0,
|
|
156
|
+
width: r?.width ?? 0,
|
|
157
|
+
height: r?.height ?? 0,
|
|
158
|
+
opacity: show ? 1 : 0,
|
|
159
|
+
}}
|
|
160
|
+
transition={appearing ? APPEAR : SPRING}
|
|
161
|
+
style={{ position: "absolute", pointerEvents: show ? "auto" : "none" }}
|
|
162
|
+
>
|
|
163
|
+
{it.render()}
|
|
164
|
+
</motion.div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
package/src/Present.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { useTheme } from "@liebstoeckel/plugin-ui";
|
|
4
|
+
import { themeToCss, type Theme } from "@liebstoeckel/theme";
|
|
5
|
+
import { registerPluginInstance, type PluginDef } from "@liebstoeckel/plugin-sdk";
|
|
6
|
+
import { Deck, type DeckProps } from "./Deck";
|
|
7
|
+
import { PresenterView } from "./PresenterView";
|
|
8
|
+
import { CaptureView } from "./CaptureView";
|
|
9
|
+
import { PrintView } from "./PrintView";
|
|
10
|
+
import { LiveProvider, type LiveContextValue } from "./live/Plugin";
|
|
11
|
+
import { detectLive } from "./live/detect";
|
|
12
|
+
import { connectLive } from "./live/connect";
|
|
13
|
+
import { getParticipantId } from "./live/participant";
|
|
14
|
+
import { captureRequest, printRequest } from "./build/capture-protocol";
|
|
15
|
+
|
|
16
|
+
/** Concatenate deck-defined brand themes into one CSS string of `[data-brand]`
|
|
17
|
+
* blocks (empty when none). Pure, unit-testable without a DOM. */
|
|
18
|
+
export function brandThemesCss(themes?: Theme[]): string {
|
|
19
|
+
return (themes ?? []).map(themeToCss).join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether to render the presenter confidence monitor: the `#presenter` hash asks
|
|
23
|
+
* for it, but a live **viewer** must never reach it (it exposes speaker notes and
|
|
24
|
+
* the plugin presenter consoles). Standalone (`role === undefined`) keeps the
|
|
25
|
+
* hash-gate as the presenter mechanism (ADR 0027/0070). Pure, DOM-free. */
|
|
26
|
+
export function presenterViewRequested(hash: string, role?: string): boolean {
|
|
27
|
+
return hash.includes("presenter") && role !== "viewer";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Single entry point for a presentation. Detects a live server (the bootstrap the
|
|
31
|
+
// server injects), connects the shared Yjs doc, and provides plugin context. Falls
|
|
32
|
+
// back to the standalone deck (BroadcastChannel presenter view via the P key).
|
|
33
|
+
export function Present(props: DeckProps) {
|
|
34
|
+
// Build-time thumbnail capture short-circuits everything else: no live connect,
|
|
35
|
+
// no nav, no presenter, just a motionless slide for the headless screenshotter.
|
|
36
|
+
// Gate the live connection on it (hooks still run unconditionally).
|
|
37
|
+
const [capture] = useState(() => captureRequest());
|
|
38
|
+
const [print] = useState(() => printRequest());
|
|
39
|
+
const info = useMemo(() => (capture || print ? null : detectLive()), [capture, print]);
|
|
40
|
+
const participant = useMemo(() => getParticipantId(), []);
|
|
41
|
+
const theme = useTheme();
|
|
42
|
+
const registry = useMemo(
|
|
43
|
+
() =>
|
|
44
|
+
Object.fromEntries((props.plugins ?? []).map((p) => [p.id, p])) as Record<
|
|
45
|
+
string,
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
+
PluginDef<any>
|
|
48
|
+
>,
|
|
49
|
+
[props.plugins],
|
|
50
|
+
);
|
|
51
|
+
const conn = useMemo(() => (info ? connectLive(info, participant) : null), [info, participant]);
|
|
52
|
+
const doc = useMemo(() => conn?.doc ?? new Y.Doc(), [conn]);
|
|
53
|
+
|
|
54
|
+
// A plugin with global surfaces + a presenter console (e.g. Q&A) can be used without an
|
|
55
|
+
// on-slide placement, so register its default instance in the doc index, otherwise the
|
|
56
|
+
// presenter console, which discovers instances from placements (ADR 0033), wouldn't find
|
|
57
|
+
// it. Runs in both the Deck and the presenter window, so it doesn't depend on a viewer
|
|
58
|
+
// being connected. (ADR 0036)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!info) return;
|
|
61
|
+
for (const [id, def] of Object.entries(registry)) {
|
|
62
|
+
if (def.client.presenter && def.client.global) registerPluginInstance(doc, id, "");
|
|
63
|
+
}
|
|
64
|
+
}, [info, registry, doc]);
|
|
65
|
+
|
|
66
|
+
const value: LiveContextValue = {
|
|
67
|
+
live: !!info,
|
|
68
|
+
role: info?.role ?? "presenter",
|
|
69
|
+
participant,
|
|
70
|
+
doc,
|
|
71
|
+
theme,
|
|
72
|
+
viewerUrl: info?.viewer,
|
|
73
|
+
plugins: registry,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The presenter confidence monitor is selected by the #presenter hash, but in a
|
|
77
|
+
// live session a *viewer* must never reach it (it leaks speaker notes + presenter
|
|
78
|
+
// consoles). Standalone (no live role) keeps the hash-gate (ADR 0027/0070).
|
|
79
|
+
const [isPresenterWindow] = useState(
|
|
80
|
+
() => typeof location !== "undefined" && presenterViewRequested(location.hash, info?.role),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Deck-defined brands → an injected `[data-brand]` stylesheet (so a deck can ship
|
|
84
|
+
// its own brand without editing the theme package). Rendered in every view.
|
|
85
|
+
const css = brandThemesCss(props.brandThemes);
|
|
86
|
+
const brandStyle = css ? <style data-brand-themes>{css}</style> : null;
|
|
87
|
+
|
|
88
|
+
if (capture)
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
{brandStyle}
|
|
92
|
+
<CaptureView {...props} />
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (print)
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
{brandStyle}
|
|
100
|
+
<PrintView {...props} />
|
|
101
|
+
</>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// The confidence monitor (#presenter) works standalone (BroadcastChannel) and
|
|
105
|
+
// live (shared Yjs doc), it reads the same controller as the audience Deck.
|
|
106
|
+
const view = isPresenterWindow ? <PresenterView {...props} /> : <Deck {...props} />;
|
|
107
|
+
return (
|
|
108
|
+
<LiveProvider value={value}>
|
|
109
|
+
{brandStyle}
|
|
110
|
+
{view}
|
|
111
|
+
</LiveProvider>
|
|
112
|
+
);
|
|
113
|
+
}
|