@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.
Files changed (51) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +82 -0
  3. package/package.json +70 -0
  4. package/src/CaptureView.tsx +96 -0
  5. package/src/CodeMagic.tsx +76 -0
  6. package/src/Deck.tsx +286 -0
  7. package/src/DeckChrome.tsx +240 -0
  8. package/src/HelpOverlay.tsx +156 -0
  9. package/src/MobileHint.tsx +71 -0
  10. package/src/PersistentLayer.tsx +168 -0
  11. package/src/Present.tsx +113 -0
  12. package/src/PresenterView.tsx +454 -0
  13. package/src/PrintView.tsx +151 -0
  14. package/src/QrOverlay.tsx +133 -0
  15. package/src/Stage.tsx +82 -0
  16. package/src/Thumb.tsx +36 -0
  17. package/src/build/buildDeck.ts +321 -0
  18. package/src/build/capture-protocol.ts +55 -0
  19. package/src/build/licenses.ts +336 -0
  20. package/src/build/mdx-plugin.ts +30 -0
  21. package/src/build/source-attr.ts +4 -0
  22. package/src/build/source-package.ts +210 -0
  23. package/src/build/thumbnails.ts +49 -0
  24. package/src/build/visx-esm-plugin.ts +42 -0
  25. package/src/code/diff.ts +61 -0
  26. package/src/code/macro.ts +24 -0
  27. package/src/code/tokenize.ts +72 -0
  28. package/src/code/types.ts +24 -0
  29. package/src/delivery.ts +32 -0
  30. package/src/index.ts +55 -0
  31. package/src/live/Plugin.tsx +160 -0
  32. package/src/live/PluginBoundary.tsx +34 -0
  33. package/src/live/breakout.tsx +235 -0
  34. package/src/live/connect.ts +149 -0
  35. package/src/live/deckIndex.ts +77 -0
  36. package/src/live/detect.ts +17 -0
  37. package/src/live/globalChrome.tsx +185 -0
  38. package/src/live/globals.ts +15 -0
  39. package/src/live/index.ts +7 -0
  40. package/src/live/participant.ts +41 -0
  41. package/src/live/presenterPanel.tsx +281 -0
  42. package/src/live/ui.ts +8 -0
  43. package/src/mobile.ts +59 -0
  44. package/src/nav.ts +149 -0
  45. package/src/slides.ts +19 -0
  46. package/src/source.ts +9 -0
  47. package/src/steps.tsx +117 -0
  48. package/src/thumbnails.ts +31 -0
  49. package/src/transitions.ts +88 -0
  50. package/src/useCoarsePointer.ts +17 -0
  51. package/src/useDeckSync.ts +85 -0
package/src/steps.tsx ADDED
@@ -0,0 +1,117 @@
1
+ import { createContext, useContext, useId, useLayoutEffect, useState, type ReactNode } from "react";
2
+ import { motion } from "motion/react";
3
+
4
+ interface Entry {
5
+ id: string;
6
+ weight: number;
7
+ }
8
+
9
+ interface StepsApi {
10
+ step: number;
11
+ /** Claim `weight` consecutive reveal slots (default 1). Idempotent per id. */
12
+ register(id: string, weight?: number): void;
13
+ unregister(id: string): void;
14
+ /** 1-based index of the first slot this id occupies, or 0 until registered. */
15
+ startOf(id: string): number;
16
+ }
17
+
18
+ const StepsCtx = createContext<StepsApi | null>(null);
19
+
20
+ /** The index of the slide a subtree belongs to. During the AnimatePresence overlap
21
+ * the exiting slide still carries its own (old) index here, so descendants, e.g.
22
+ * a persistent `<Slot>`, can tell whether they're on the *current* slide. -1
23
+ * outside any slide. */
24
+ export const SlideIndexContext = createContext(-1);
25
+
26
+ /** Wraps the active slide; tracks the total reveal slots it contains (sum of each
27
+ * consumer's weight) and the current reveal index. Reports `total` via onTotal
28
+ * *with its slide index* so the deck can ignore a slide that's exiting (the
29
+ * AnimatePresence overlap). Uses layout effects so `total` is set before any
30
+ * keypress can advance the slide. */
31
+ export function StepsProvider({
32
+ step,
33
+ slideIndex,
34
+ onTotal,
35
+ children,
36
+ }: {
37
+ step: number;
38
+ slideIndex: number;
39
+ onTotal?: (slideIndex: number, total: number) => void;
40
+ children: ReactNode;
41
+ }) {
42
+ const [entries, setEntries] = useState<Entry[]>([]);
43
+ const api: StepsApi = {
44
+ step,
45
+ register: (id, weight = 1) =>
46
+ setEntries((arr) => {
47
+ const i = arr.findIndex((e) => e.id === id);
48
+ if (i === -1) return [...arr, { id, weight }];
49
+ if (arr[i]!.weight === weight) return arr;
50
+ const copy = arr.slice();
51
+ copy[i] = { id, weight };
52
+ return copy;
53
+ }),
54
+ unregister: (id) => setEntries((arr) => arr.filter((e) => e.id !== id)),
55
+ startOf: (id) => {
56
+ let acc = 0;
57
+ for (const e of entries) {
58
+ if (e.id === id) return acc + 1;
59
+ acc += e.weight;
60
+ }
61
+ return 0;
62
+ },
63
+ };
64
+ const total = entries.reduce((s, e) => s + e.weight, 0);
65
+ useLayoutEffect(() => {
66
+ onTotal?.(slideIndex, total);
67
+ }, [total, slideIndex, onTotal]);
68
+ return (
69
+ <StepsCtx.Provider value={api}>
70
+ <SlideIndexContext.Provider value={slideIndex}>{children}</SlideIndexContext.Provider>
71
+ </StepsCtx.Provider>
72
+ );
73
+ }
74
+
75
+ /** A progressive reveal. Hidden until the deck's step reaches its slot (its
76
+ * position among siblings). Outside a StepsProvider it's always shown. */
77
+ export function Step({ children, className }: { children?: ReactNode; className?: string }) {
78
+ const ctx = useContext(StepsCtx);
79
+ const id = useId();
80
+ useLayoutEffect(() => {
81
+ ctx?.register(id, 1);
82
+ return () => ctx?.unregister(id);
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, []);
85
+ const start = ctx ? ctx.startOf(id) : 1;
86
+ const shown = !ctx ? true : start > 0 && ctx.step >= start;
87
+ return (
88
+ <motion.div
89
+ className={className}
90
+ initial={false}
91
+ animate={{ opacity: shown ? 1 : 0, y: shown ? 0 : 8 }}
92
+ transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
93
+ aria-hidden={!shown}
94
+ >
95
+ {children}
96
+ </motion.div>
97
+ );
98
+ }
99
+
100
+ /** For a multi-state reveal (e.g. animated code): claims `count - 1` reveal slots
101
+ * and returns the active state index (0…count-1) as the deck steps through them.
102
+ * Outside a provider (thumbnail capture / standalone) it resolves to the final
103
+ * state so a static render shows the finished result. */
104
+ export function useRevealState(count: number): number {
105
+ const ctx = useContext(StepsCtx);
106
+ const id = useId();
107
+ const weight = Math.max(0, count - 1);
108
+ useLayoutEffect(() => {
109
+ ctx?.register(id, weight);
110
+ return () => ctx?.unregister(id);
111
+ // eslint-disable-next-line react-hooks/exhaustive-deps
112
+ }, [weight]);
113
+ if (!ctx) return Math.max(0, count - 1);
114
+ const start = ctx.startOf(id);
115
+ if (start === 0) return 0; // not registered on this render yet
116
+ return Math.max(0, Math.min(count - 1, ctx.step - start + 1));
117
+ }
@@ -0,0 +1,31 @@
1
+ import { parseThumbnails, THUMBS_ATTR } from "./build/thumbnails";
2
+
3
+ export interface ThumbnailSet {
4
+ w: number;
5
+ h: number;
6
+ /** data-URI for a slide, or undefined if not captured */
7
+ get(index: number): string | undefined;
8
+ }
9
+
10
+ let cache: ThumbnailSet | null | undefined;
11
+
12
+ /** Read the thumbnails manifest embedded in the current document, if any.
13
+ * Memoized (the manifest is static for a loaded deck). No DOM / no block → null,
14
+ * so callers fall back to the live <DeckThumb>. */
15
+ export function readThumbnails(): ThumbnailSet | null {
16
+ if (cache !== undefined) return cache;
17
+ cache = parse();
18
+ return cache;
19
+ }
20
+
21
+ function parse(): ThumbnailSet | null {
22
+ if (typeof document === "undefined") return null;
23
+ const el = document.querySelector(`script[${THUMBS_ATTR}]`);
24
+ if (!el?.textContent) return null;
25
+ try {
26
+ const m = parseThumbnails(el.textContent);
27
+ return { w: m.w, h: m.h, get: (i) => m.thumbs[i] };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
@@ -0,0 +1,88 @@
1
+ import type { Transition, Variants } from "motion/react";
2
+
3
+ /** Nav direction for a slide change: forward (next) or backward (prev). Fed to
4
+ * the variants as Motion `custom`, so directional transitions mirror on back-nav. */
5
+ export type SlideDirection = 1 | -1;
6
+
7
+ /** A built-in preset name. */
8
+ export type SlideTransitionName = "fade" | "blur" | "slide" | "zoom" | "none";
9
+
10
+ /** A slide transition expressed as Motion variants (`enter`/`center`/`exit`) plus
11
+ * base timing. A variant value may be `(dir: SlideDirection) => target` so the
12
+ * transition can mirror on the navigation direction (see `slide`). */
13
+ export interface SlideTransitionSpec {
14
+ variants: Variants;
15
+ transition?: Transition;
16
+ }
17
+
18
+ /** Either a built-in preset name or a custom spec. */
19
+ export type SlideTransition = SlideTransitionName | SlideTransitionSpec;
20
+
21
+ const EASE = [0.22, 1, 0.36, 1] as const;
22
+
23
+ /** Built-in presets. `enter` is the off-state a slide animates *from*, `center`
24
+ * is on-screen, `exit` is the off-state the leaving slide animates *to*. */
25
+ export const TRANSITIONS: Record<SlideTransitionName, SlideTransitionSpec> = {
26
+ // Default: a light opacity cross-fade, no transform, no blur.
27
+ fade: {
28
+ variants: { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } },
29
+ transition: { duration: 0.32, ease: "easeInOut" },
30
+ },
31
+ // The former default: opacity + a soft blur and a micro-scale.
32
+ blur: {
33
+ variants: {
34
+ enter: { opacity: 0, filter: "blur(10px)", scale: 1.015 },
35
+ center: { opacity: 1, filter: "blur(0px)", scale: 1 },
36
+ exit: { opacity: 0, filter: "blur(10px)", scale: 0.99 },
37
+ },
38
+ transition: { duration: 0.5, ease: EASE },
39
+ },
40
+ // Directional horizontal push; mirrors on back-nav via `custom`.
41
+ slide: {
42
+ variants: {
43
+ enter: (d: SlideDirection) => ({ x: d > 0 ? "100%" : "-100%", opacity: 1 }),
44
+ center: { x: 0, opacity: 1 },
45
+ exit: (d: SlideDirection) => ({ x: d > 0 ? "-100%" : "100%", opacity: 1 }),
46
+ },
47
+ transition: { duration: 0.55, ease: EASE },
48
+ },
49
+ // A gentle scale + fade "punch in".
50
+ zoom: {
51
+ variants: {
52
+ enter: { opacity: 0, scale: 0.92 },
53
+ center: { opacity: 1, scale: 1 },
54
+ exit: { opacity: 0, scale: 1.06 },
55
+ },
56
+ transition: { duration: 0.45, ease: EASE },
57
+ },
58
+ // Instant cut, no animation.
59
+ none: {
60
+ variants: { enter: { opacity: 1 }, center: { opacity: 1 }, exit: { opacity: 0 } },
61
+ transition: { duration: 0 },
62
+ },
63
+ };
64
+
65
+ /** `prefers-reduced-motion`: strip transforms/blur, keep only a tiny opacity fade. */
66
+ const REDUCED: SlideTransitionSpec = {
67
+ variants: { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } },
68
+ transition: { duration: 0.12 },
69
+ };
70
+
71
+ /** The transition used when neither the slide nor the deck sets one. */
72
+ export const DEFAULT_TRANSITION: SlideTransitionName = "fade";
73
+
74
+ /** Resolve a name / custom spec / `undefined` into a concrete spec. Reduced motion
75
+ * always wins (accessibility); an unknown name falls back to the default. */
76
+ export function resolveTransition(t: SlideTransition | undefined, reduceMotion = false): SlideTransitionSpec {
77
+ if (reduceMotion) return REDUCED;
78
+ if (t == null) return TRANSITIONS[DEFAULT_TRANSITION];
79
+ if (typeof t === "string") return TRANSITIONS[t] ?? TRANSITIONS[DEFAULT_TRANSITION];
80
+ return t;
81
+ }
82
+
83
+ /** On a coarse-pointer (mobile/touch) device, slide transitions are disabled by
84
+ * default, they're janky on a heavily down-scaled stage and snappy cuts read
85
+ * better there. Set `Present`'s `mobileTransitions` to opt back in. */
86
+ export function mobileTransitionsDisabled(coarse: boolean, mobileTransitions = false): boolean {
87
+ return coarse && !mobileTransitions;
88
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** True on a coarse (touch) pointer. Used to upsize tap targets, swap the
4
+ * keyboard help for the ⋮ menu, and (by default) drop slide transitions on
5
+ * mobile. SSR / no-`matchMedia` safe (returns false). */
6
+ export function useCoarsePointer(): boolean {
7
+ const [coarse, setCoarse] = useState(false);
8
+ useEffect(() => {
9
+ if (typeof matchMedia !== "function") return;
10
+ const mq = matchMedia("(pointer: coarse)");
11
+ const update = () => setCoarse(mq.matches);
12
+ update();
13
+ mq.addEventListener?.("change", update);
14
+ return () => mq.removeEventListener?.("change", update);
15
+ }, []);
16
+ return coarse;
17
+ }
@@ -0,0 +1,85 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { stepForward, stepBack } from "./delivery";
3
+
4
+ // Cross-window sync over BroadcastChannel. The audience window and the presenter
5
+ // window share { index, step, total, startedAt }; either can drive. On open, a new
6
+ // window broadcasts a "request" and the others reply so it snaps to the live state.
7
+ export type DeckState = { index: number; step: number; total: number; startedAt: number };
8
+ type Msg = ({ type: "state" } & DeckState) | { type: "request" };
9
+
10
+ const CHANNEL = "liebstoeckel";
11
+
12
+ export function useDeckSync(count: number) {
13
+ const [state, setState] = useState<DeckState>(() => ({ index: 0, step: 0, total: 0, startedAt: Date.now() }));
14
+ const ref = useRef(state);
15
+ ref.current = state;
16
+ const chan = useRef<BroadcastChannel | null>(null);
17
+ const clamp = (n: number) => Math.min(Math.max(n, 0), Math.max(count - 1, 0));
18
+
19
+ useEffect(() => {
20
+ const ch = new BroadcastChannel(CHANNEL);
21
+ chan.current = ch;
22
+ ch.onmessage = (e: MessageEvent<Msg>) => {
23
+ const m = e.data;
24
+ if (m.type === "state") {
25
+ setState((s) =>
26
+ s.index === m.index && s.step === m.step && s.total === m.total && s.startedAt === m.startedAt
27
+ ? s
28
+ : { index: m.index, step: m.step, total: m.total, startedAt: m.startedAt },
29
+ );
30
+ } else if (m.type === "request") {
31
+ ch.postMessage({ type: "state", ...ref.current });
32
+ }
33
+ };
34
+ ch.postMessage({ type: "request" });
35
+ return () => ch.close();
36
+ }, []);
37
+
38
+ const commit = useCallback((patch: Partial<DeckState>) => {
39
+ setState((s) => {
40
+ const ns = { ...s, ...patch };
41
+ chan.current?.postMessage({ type: "state", ...ns });
42
+ return ns;
43
+ });
44
+ }, []);
45
+
46
+ const setIndex = useCallback(
47
+ (updater: number | ((n: number) => number)) =>
48
+ commit({ index: clamp(typeof updater === "function" ? updater(ref.current.index) : updater) }),
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ [commit, count],
51
+ );
52
+ const setStep = useCallback((step: number) => commit({ step }), [commit]);
53
+ const setTotal = useCallback((total: number) => {
54
+ if (ref.current.total !== total) commit({ total });
55
+ }, [commit]);
56
+ const resetTimer = useCallback(() => commit({ startedAt: Date.now() }), [commit]);
57
+
58
+ // next/prev read the freshest state (ref) so rapid presses don't read stale step
59
+ const next = useCallback(() => {
60
+ const s = ref.current;
61
+ const r = stepForward(s.step, s.total);
62
+ commit(r.advanceSlide ? { index: clamp(s.index + 1), step: 0 } : { step: r.step });
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, [commit, count]);
65
+ const prev = useCallback(() => {
66
+ const s = ref.current;
67
+ const r = stepBack(s.step);
68
+ commit(r.retreatSlide ? { index: clamp(s.index - 1), step: 0 } : { step: r.step });
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [commit, count]);
71
+
72
+ return {
73
+ index: state.index,
74
+ step: state.step,
75
+ total: state.total,
76
+ startedAt: state.startedAt,
77
+ canDrive: true,
78
+ setIndex,
79
+ setStep,
80
+ setTotal,
81
+ resetTimer,
82
+ next,
83
+ prev,
84
+ };
85
+ }