@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,41 @@
|
|
|
1
|
+
const KEY = "liebstoeckel:pid";
|
|
2
|
+
|
|
3
|
+
type Store = Pick<Storage, "getItem" | "setItem">;
|
|
4
|
+
|
|
5
|
+
// crypto.randomUUID only exists in secure contexts (https/localhost). Live decks
|
|
6
|
+
// are served over http on a LAN IP, so fall back to a non-crypto random id.
|
|
7
|
+
function uuid(): string {
|
|
8
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
9
|
+
return `p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** sessionStorage, or undefined if it isn't usable. Merely *accessing* the
|
|
13
|
+
* property throws ("Access is denied") in an opaque origin, a sandboxed deck
|
|
14
|
+
* (relay's `CSP: sandbox`) or a `setContent`/`data:` document (thumbnail capture)
|
|
15
|
+
* , so the access itself must be guarded, not just a typeof check. */
|
|
16
|
+
function safeSessionStorage(): Store | undefined {
|
|
17
|
+
try {
|
|
18
|
+
const s = sessionStorage;
|
|
19
|
+
return s ?? undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stable id for this browser session (one session = one participant). Persists
|
|
26
|
+
* in sessionStorage so a reload keeps identity (no double-counting votes). When
|
|
27
|
+
* storage is denied (opaque origin), falls back to an ephemeral per-load id, * navigation still works; identity just doesn't survive a reload there. */
|
|
28
|
+
export function getParticipantId(storage?: Store): string {
|
|
29
|
+
const store = storage ?? safeSessionStorage();
|
|
30
|
+
if (!store) return uuid();
|
|
31
|
+
try {
|
|
32
|
+
let id = store.getItem(KEY);
|
|
33
|
+
if (!id) {
|
|
34
|
+
id = uuid();
|
|
35
|
+
store.setItem(KEY, id);
|
|
36
|
+
}
|
|
37
|
+
return id;
|
|
38
|
+
} catch {
|
|
39
|
+
return uuid();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { LayoutGroup } from "motion/react";
|
|
3
|
+
import { observePluginIndex, readPluginInstances, type ClientProps, type PluginDef } from "@liebstoeckel/plugin-sdk";
|
|
4
|
+
import { useLive, usePluginProps, type LiveContextValue } from "./Plugin";
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
type Inst = { type: string; instance: string; title?: string; def: PluginDef<any> };
|
|
8
|
+
|
|
9
|
+
type Variant = "desktop" | "mobile";
|
|
10
|
+
|
|
11
|
+
const ikey = (i: Inst) => `${i.type} ${i.instance}`;
|
|
12
|
+
|
|
13
|
+
/** Live list of placed instances whose type exposes a presenter console, discovered from
|
|
14
|
+
* the doc index (ADR 0033) and kept current as `<Plugin>`s mount across the session. */
|
|
15
|
+
function usePresenterInstances(ctx: LiveContextValue | null): Inst[] {
|
|
16
|
+
const [list, setList] = useState<Inst[]>([]);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!ctx?.live) {
|
|
19
|
+
setList([]);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const read = () =>
|
|
23
|
+
setList(
|
|
24
|
+
readPluginInstances(ctx.doc)
|
|
25
|
+
.map((e) => ({ type: e.type, instance: e.instance, title: e.title, def: ctx.plugins[e.type]! }))
|
|
26
|
+
.filter((e) => e.def?.client.presenter),
|
|
27
|
+
);
|
|
28
|
+
read();
|
|
29
|
+
return observePluginIndex(ctx.doc, read);
|
|
30
|
+
}, [ctx?.live, ctx?.doc, ctx?.plugins]);
|
|
31
|
+
return list;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Notes container, matches the panel the presenter view used before tabs existed, so a
|
|
35
|
+
// deck with no presenter instances looks the same (ADR 0031).
|
|
36
|
+
const NOTES_CLASS: Record<Variant, string> = {
|
|
37
|
+
desktop:
|
|
38
|
+
"presenter-notes min-h-0 min-w-0 flex-1 overflow-auto rounded-2xl border border-border bg-surface/40 p-6 text-xl leading-relaxed text-text/90",
|
|
39
|
+
mobile: "presenter-notes min-h-0 flex-1 overflow-auto px-5 py-4 text-2xl leading-relaxed text-text/90",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function NotesLabel() {
|
|
43
|
+
return <div className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.3em] text-muted">Speaker notes</div>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tabClass(variant: Variant, selected: boolean): string {
|
|
47
|
+
const base = "flex shrink-0 items-center gap-2 rounded-lg border font-mono uppercase tracking-wider transition";
|
|
48
|
+
const size = variant === "mobile" ? "px-3 py-2 text-[11px]" : "px-3 py-1.5 text-xs";
|
|
49
|
+
const state = selected
|
|
50
|
+
? "border-primary bg-primary/10 text-primary"
|
|
51
|
+
: variant === "mobile"
|
|
52
|
+
? "border-border text-muted active:border-text active:text-text"
|
|
53
|
+
: "border-border text-muted hover:border-text hover:text-text";
|
|
54
|
+
return `${base} ${size} ${state}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Badge({ value }: { value: number | string }) {
|
|
58
|
+
return <span className="rounded-full bg-accent px-1.5 py-px text-[10px] font-semibold text-on-primary tabular-nums">{value}</span>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One **per-type** strip tab (ADR 0033/0035): one "Poll" tab regardless of how many poll
|
|
62
|
+
* instances exist. Its badge is the instance count when there are several, else the lone
|
|
63
|
+
* instance's own badge. Subscribes to the first instance for that live badge. */
|
|
64
|
+
function TypeTab({ ctx, group, selected, variant, onSelect }: { ctx: LiveContextValue; group: Inst[]; selected: boolean; variant: Variant; onSelect: () => void }) {
|
|
65
|
+
const first = group[0]!;
|
|
66
|
+
const props = usePluginProps(ctx, first.type, first.def, {}, first.instance);
|
|
67
|
+
const surface = first.def.client.presenter!;
|
|
68
|
+
const multi = group.length > 1;
|
|
69
|
+
const badge = multi ? group.length : surface.badge?.(props.snapshot);
|
|
70
|
+
const show = badge !== undefined && badge !== 0 && badge !== "";
|
|
71
|
+
return (
|
|
72
|
+
<button onClick={onSelect} className={tabClass(variant, selected)}>
|
|
73
|
+
{surface.icon != null && <span aria-hidden>{surface.icon as ReactNode}</span>}
|
|
74
|
+
<span>{surface.label}</span>
|
|
75
|
+
{show && <Badge value={badge!} />}
|
|
76
|
+
</button>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The instance's live label: its title (e.g. the poll question), then placement title,
|
|
81
|
+
* then the instance id, then the type label as a last resort. */
|
|
82
|
+
function instLabel(inst: Inst, snapshot: unknown): string {
|
|
83
|
+
const s = inst.def.client.presenter!;
|
|
84
|
+
return s.title?.(snapshot as never) || inst.title || inst.instance || s.label;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Dropdown trigger, shows the selected instance's label on one truncated line. */
|
|
88
|
+
function InstanceTrigger({ ctx, inst, open, onToggle, variant }: { ctx: LiveContextValue; inst: Inst; open: boolean; onToggle: () => void; variant: Variant }) {
|
|
89
|
+
const props = usePluginProps(ctx, inst.type, inst.def, {}, inst.instance);
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
onClick={onToggle}
|
|
93
|
+
aria-haspopup="listbox"
|
|
94
|
+
aria-expanded={open}
|
|
95
|
+
className={`flex w-full items-center justify-between gap-2 rounded-lg border px-3 ${variant === "mobile" ? "py-2.5 text-base" : "py-2 text-sm"} text-left transition ${
|
|
96
|
+
open ? "border-primary text-text" : "border-border text-text/90 hover:border-text"
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
<span className="min-w-0 flex-1 truncate">{instLabel(inst, props.snapshot)}</span>
|
|
100
|
+
<svg className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
101
|
+
<path d="M6 9l6 6 6-6" />
|
|
102
|
+
</svg>
|
|
103
|
+
</button>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One row in the open instance menu, full label (wraps), check when selected, + badge. */
|
|
108
|
+
function InstanceMenuItem({ ctx, inst, selected, onPick }: { ctx: LiveContextValue; inst: Inst; selected: boolean; onPick: () => void }) {
|
|
109
|
+
const props = usePluginProps(ctx, inst.type, inst.def, {}, inst.instance);
|
|
110
|
+
const surface = inst.def.client.presenter!;
|
|
111
|
+
const badge = surface.badge?.(props.snapshot);
|
|
112
|
+
const show = badge !== undefined && badge !== 0 && badge !== "";
|
|
113
|
+
return (
|
|
114
|
+
<button
|
|
115
|
+
role="option"
|
|
116
|
+
aria-selected={selected}
|
|
117
|
+
onClick={onPick}
|
|
118
|
+
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition hover:bg-primary/10 ${selected ? "text-primary" : "text-text/90"}`}
|
|
119
|
+
>
|
|
120
|
+
<span className="w-3 shrink-0 pt-0.5">{selected ? "✓" : ""}</span>
|
|
121
|
+
<span className="min-w-0 flex-1">{instLabel(inst, props.snapshot)}</span>
|
|
122
|
+
{show && <span className="pt-0.5"><Badge value={badge!} /></span>}
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Instance switcher for a multi-instance type (ADR 0035): a compact dropdown in the
|
|
128
|
+
* console header. The trigger shows the current instance (truncated); the menu lists
|
|
129
|
+
* every instance with its full label (wrapping) so long poll questions never overflow. */
|
|
130
|
+
function InstanceDropdown({ ctx, group, selectedKey, onPick, variant }: { ctx: LiveContextValue; group: Inst[]; selectedKey: string; onPick: (key: string) => void; variant: Variant }) {
|
|
131
|
+
const [open, setOpen] = useState(false);
|
|
132
|
+
const sel = group.find((i) => ikey(i) === selectedKey) ?? group[0]!;
|
|
133
|
+
return (
|
|
134
|
+
<div className={`relative shrink-0 ${variant === "mobile" ? "px-4" : ""}`}>
|
|
135
|
+
<InstanceTrigger ctx={ctx} inst={sel} open={open} onToggle={() => setOpen((o) => !o)} variant={variant} />
|
|
136
|
+
{open && (
|
|
137
|
+
<>
|
|
138
|
+
<div className="fixed inset-0 z-20" aria-hidden onClick={() => setOpen(false)} />
|
|
139
|
+
<div role="listbox" className={`absolute top-full z-30 mt-1 max-h-64 overflow-auto rounded-lg border border-border bg-surface shadow-2xl ${variant === "mobile" ? "left-4 right-4" : "left-0 right-0"}`}>
|
|
140
|
+
{group.map((i) => (
|
|
141
|
+
<InstanceMenuItem key={ikey(i)} ctx={ctx} inst={i} selected={ikey(i) === ikey(sel)} onPick={() => { onPick(ikey(i)); setOpen(false); }} />
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The selected instance's console, mounted with its live state. */
|
|
151
|
+
function Console({ ctx, inst, variant }: { ctx: LiveContextValue; inst: Inst; variant: Variant }) {
|
|
152
|
+
const props = usePluginProps(ctx, inst.type, inst.def, {}, inst.instance);
|
|
153
|
+
const C = inst.def.client.presenter!.Console as ComponentType<ClientProps<unknown>>;
|
|
154
|
+
// Mobile is edge-to-edge, so the bordered console insets itself; desktop fills the column.
|
|
155
|
+
const box = variant === "mobile" ? "mx-4 mb-3 p-4" : "p-5";
|
|
156
|
+
// Namespace the console's Motion layout tree so a plugin whose UI uses `layoutId`
|
|
157
|
+
// (e.g. Q&A's ranked rows) doesn't share layout identity with the SAME slide rendered
|
|
158
|
+
// live in the presenter's preview Thumb, which sits inside the ScaledStage's
|
|
159
|
+
// `transform`. Without this, Motion treats the two as one shared element and morphs
|
|
160
|
+
// across the scale boundary (the ADR 0026 hazard).
|
|
161
|
+
return (
|
|
162
|
+
<div className={`min-h-0 min-w-0 flex-1 overflow-auto rounded-2xl border border-border bg-surface/40 ${box}`}>
|
|
163
|
+
<LayoutGroup id={`presenter-console-${ikey(inst)}`}>
|
|
164
|
+
<C {...props} />
|
|
165
|
+
</LayoutGroup>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The maximize ⤢ / restore ⤡ button + the "focused" affordance (ADR 0032). */
|
|
171
|
+
function FocusControl({ focused, onToggle }: { focused: boolean; onToggle: () => void }) {
|
|
172
|
+
return (
|
|
173
|
+
<div className="ml-auto flex shrink-0 items-center gap-2 pl-2">
|
|
174
|
+
{focused && <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-accent">focused · Esc</span>}
|
|
175
|
+
<button
|
|
176
|
+
onClick={onToggle}
|
|
177
|
+
aria-label={focused ? "Restore layout" : "Maximize pane"}
|
|
178
|
+
title={focused ? "Restore (Esc)" : "Maximize (z)"}
|
|
179
|
+
className="flex h-7 w-7 items-center justify-center rounded-lg border border-border text-muted transition hover:border-text hover:text-text"
|
|
180
|
+
>
|
|
181
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
182
|
+
{focused ? (
|
|
183
|
+
<path d="M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M16 21v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
|
|
184
|
+
) : (
|
|
185
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M16 21h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
|
186
|
+
)}
|
|
187
|
+
</svg>
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The presenter view's dominant content region: a tab strip switching between the
|
|
194
|
+
* speaker **Notes** (default) and **one tab per plugin type** that exposes a presenter
|
|
195
|
+
* surface (ADR 0031/0033). A type with several instances exposes them through a compact
|
|
196
|
+
* dropdown in the console header, not one tab each (ADR 0035). Plus a maximize toggle (ADR 0032).
|
|
197
|
+
* Identical structure desktop/mobile; with no instances the strip is just the notes label. */
|
|
198
|
+
export function PresenterPanel({
|
|
199
|
+
notes,
|
|
200
|
+
variant,
|
|
201
|
+
focused = false,
|
|
202
|
+
onToggleFocus,
|
|
203
|
+
}: {
|
|
204
|
+
notes: ReactNode;
|
|
205
|
+
variant: Variant;
|
|
206
|
+
focused?: boolean;
|
|
207
|
+
onToggleFocus?: () => void;
|
|
208
|
+
}) {
|
|
209
|
+
const ctx = useLive();
|
|
210
|
+
const instances = usePresenterInstances(ctx);
|
|
211
|
+
// group by type, preserving first-seen order
|
|
212
|
+
const groups = useMemo(() => {
|
|
213
|
+
const m = new Map<string, Inst[]>();
|
|
214
|
+
for (const i of instances) {
|
|
215
|
+
const g = m.get(i.type);
|
|
216
|
+
if (g) g.push(i);
|
|
217
|
+
else m.set(i.type, [i]);
|
|
218
|
+
}
|
|
219
|
+
return [...m.values()];
|
|
220
|
+
}, [instances]);
|
|
221
|
+
|
|
222
|
+
const [tab, setTab] = useState<string>("notes"); // "notes" | `t:<type>`
|
|
223
|
+
const [pick, setPick] = useState<Record<string, string>>({}); // type → chosen ikey
|
|
224
|
+
|
|
225
|
+
const selectedGroup = groups.find((g) => `t:${g[0]!.type}` === tab) ?? null;
|
|
226
|
+
const showingNotes = !selectedGroup;
|
|
227
|
+
const selectedInst = selectedGroup ? selectedGroup.find((i) => ikey(i) === pick[selectedGroup[0]!.type]) ?? selectedGroup[0]! : null;
|
|
228
|
+
const pickerFor = selectedGroup && selectedGroup.length > 1 ? selectedGroup : null;
|
|
229
|
+
const stripPad = variant === "mobile" ? "px-4 pt-2 pb-1" : "pb-0.5";
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
<div className={`flex shrink-0 items-center gap-1.5 overflow-x-auto ${stripPad}`}>
|
|
234
|
+
{groups.length === 0 ? (
|
|
235
|
+
<NotesLabel />
|
|
236
|
+
) : (
|
|
237
|
+
<>
|
|
238
|
+
<button onClick={() => setTab("notes")} className={tabClass(variant, showingNotes)}>
|
|
239
|
+
Notes
|
|
240
|
+
</button>
|
|
241
|
+
{ctx &&
|
|
242
|
+
groups.map((g) => (
|
|
243
|
+
<TypeTab
|
|
244
|
+
key={g[0]!.type}
|
|
245
|
+
ctx={ctx}
|
|
246
|
+
group={g}
|
|
247
|
+
variant={variant}
|
|
248
|
+
selected={tab === `t:${g[0]!.type}`}
|
|
249
|
+
onSelect={() => setTab(`t:${g[0]!.type}`)}
|
|
250
|
+
/>
|
|
251
|
+
))}
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
{/* focus toggle is desktop-only: the mobile panel is already full-bleed (ADR 0032/0027) */}
|
|
255
|
+
{onToggleFocus && variant === "desktop" && <FocusControl focused={focused} onToggle={onToggleFocus} />}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{/* instance switcher (dropdown) for the selected multi-instance type, keeps long
|
|
259
|
+
poll questions out of the strip (ADR 0035) */}
|
|
260
|
+
{ctx && pickerFor && (
|
|
261
|
+
<InstanceDropdown
|
|
262
|
+
ctx={ctx}
|
|
263
|
+
group={pickerFor}
|
|
264
|
+
selectedKey={pick[pickerFor[0]!.type] ?? ikey(pickerFor[0]!)}
|
|
265
|
+
onPick={(k) => setPick((p) => ({ ...p, [pickerFor![0]!.type]: k }))}
|
|
266
|
+
variant={variant}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{/* key by selection: switching reuses this slot, but each instance's state may have a
|
|
271
|
+
different shape, without a remount the console would render one frame with the
|
|
272
|
+
previous instance's snapshot (e.g. a poll snapshot reaching the Q&A console) and
|
|
273
|
+
throw. The key forces a fresh mount → correct initial snapshot. */}
|
|
274
|
+
{showingNotes ? (
|
|
275
|
+
<div className={NOTES_CLASS[variant]}>{notes}</div>
|
|
276
|
+
) : (
|
|
277
|
+
ctx && selectedInst && <Console key={ikey(selectedInst)} ctx={ctx} inst={selectedInst} variant={variant} />
|
|
278
|
+
)}
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
package/src/live/ui.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
type CMap = Record<string, ComponentType<Record<string, unknown>>>;
|
|
4
|
+
|
|
5
|
+
/** Merge author surface overrides over plugin defaults (overrides win). */
|
|
6
|
+
export function mergeUi(base: CMap, over?: CMap): CMap {
|
|
7
|
+
return { ...base, ...(over ?? {}) };
|
|
8
|
+
}
|
package/src/mobile.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Pure helpers for the mobile/touch behaviours (Featureset 5). Kept free of React
|
|
2
|
+
// and the DOM so the decision logic is unit-testable; the hooks/components consume
|
|
3
|
+
// these.
|
|
4
|
+
|
|
5
|
+
/** Below this stage scale, controls inside the scaled canvas are too small to tap
|
|
6
|
+
* comfortably, plugins offer a full-size breakout instead. */
|
|
7
|
+
export const BREAKOUT_SCALE_MAX = 0.6;
|
|
8
|
+
|
|
9
|
+
/** Should a plugin offer tap-to-expand rather than inline interaction?
|
|
10
|
+
* Only on a coarse pointer (touch) with a shrunk stage, when allowed (not a
|
|
11
|
+
* presenter preview) and the plugin actually takes input. */
|
|
12
|
+
export function breakoutEligible(opts: {
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
coarse: boolean;
|
|
15
|
+
scale: number;
|
|
16
|
+
interactive: boolean;
|
|
17
|
+
}): boolean {
|
|
18
|
+
const { allowed, coarse, scale, interactive } = opts;
|
|
19
|
+
return allowed && coarse && interactive && scale > 0 && scale < BREAKOUT_SCALE_MAX;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TouchNav = "next" | "prev" | null;
|
|
23
|
+
|
|
24
|
+
export interface TouchGesture {
|
|
25
|
+
dx: number; // end.x - start.x
|
|
26
|
+
dy: number; // end.y - start.y
|
|
27
|
+
/** tap x position (px) and the viewport width, for edge tap-zones */
|
|
28
|
+
x: number;
|
|
29
|
+
width: number;
|
|
30
|
+
/** did the gesture start on an interactive element? (then never navigate) */
|
|
31
|
+
onInteractive: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve a finished touch into a slide move. A horizontal swipe wins; otherwise a
|
|
35
|
+
* near-stationary tap in the outer edge zones advances/retreats. Returns null for
|
|
36
|
+
* anything ambiguous or on interactive content. */
|
|
37
|
+
export function resolveTouchGesture(g: TouchGesture, opts: { swipeMin?: number; tapMax?: number; edge?: number } = {}): TouchNav {
|
|
38
|
+
const swipeMin = opts.swipeMin ?? 50;
|
|
39
|
+
const tapMax = opts.tapMax ?? 10;
|
|
40
|
+
const edge = opts.edge ?? 0.25;
|
|
41
|
+
if (g.onInteractive) return null;
|
|
42
|
+
|
|
43
|
+
// horizontal swipe
|
|
44
|
+
if (Math.abs(g.dx) > swipeMin && Math.abs(g.dx) > Math.abs(g.dy)) {
|
|
45
|
+
return g.dx < 0 ? "next" : "prev";
|
|
46
|
+
}
|
|
47
|
+
// edge tap (only when essentially stationary)
|
|
48
|
+
if (Math.abs(g.dx) <= tapMax && Math.abs(g.dy) <= tapMax && g.width > 0) {
|
|
49
|
+
const frac = g.x / g.width;
|
|
50
|
+
if (frac <= edge) return "prev";
|
|
51
|
+
if (frac >= 1 - edge) return "next";
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Selector for elements that own their own taps, touch nav ignores gestures here. */
|
|
57
|
+
export const NO_NAV_SELECTOR = "button, a, input, textarea, select, [role=button], [data-pi-no-nav]";
|
|
58
|
+
|
|
59
|
+
export const isPortrait = (w: number, h: number): boolean => h > w;
|
package/src/nav.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { resolveTouchGesture, NO_NAV_SELECTOR } from "./mobile";
|
|
3
|
+
|
|
4
|
+
/** Touch navigation: horizontal swipe + edge tap-zones, resolved by the pure
|
|
5
|
+
* `resolveTouchGesture`. Reuses `onNext`/`onPrev` so step-reveals still run before
|
|
6
|
+
* a slide change. Enable only where navigation is the user's job (standalone +
|
|
7
|
+
* presenter), a live viewer follows the presenter. Gestures on interactive
|
|
8
|
+
* elements (buttons, the plugin region) are ignored. */
|
|
9
|
+
export function useTouchNav(opts: { enabled: boolean; onNext: () => void; onPrev: () => void }) {
|
|
10
|
+
const { enabled, onNext, onPrev } = opts;
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!enabled || typeof window === "undefined") return;
|
|
13
|
+
let sx = 0;
|
|
14
|
+
let sy = 0;
|
|
15
|
+
let onInteractive = false;
|
|
16
|
+
const onStart = (e: TouchEvent) => {
|
|
17
|
+
const t = e.changedTouches[0];
|
|
18
|
+
if (!t) return;
|
|
19
|
+
sx = t.clientX;
|
|
20
|
+
sy = t.clientY;
|
|
21
|
+
const el = e.target as Element | null;
|
|
22
|
+
onInteractive = !!el?.closest?.(NO_NAV_SELECTOR);
|
|
23
|
+
};
|
|
24
|
+
const onEnd = (e: TouchEvent) => {
|
|
25
|
+
const t = e.changedTouches[0];
|
|
26
|
+
if (!t) return;
|
|
27
|
+
const move = resolveTouchGesture({
|
|
28
|
+
dx: t.clientX - sx,
|
|
29
|
+
dy: t.clientY - sy,
|
|
30
|
+
x: sx,
|
|
31
|
+
width: window.innerWidth,
|
|
32
|
+
onInteractive,
|
|
33
|
+
});
|
|
34
|
+
if (move === "next") onNext();
|
|
35
|
+
else if (move === "prev") onPrev();
|
|
36
|
+
};
|
|
37
|
+
window.addEventListener("touchstart", onStart, { passive: true });
|
|
38
|
+
window.addEventListener("touchend", onEnd, { passive: true });
|
|
39
|
+
return () => {
|
|
40
|
+
window.removeEventListener("touchstart", onStart);
|
|
41
|
+
window.removeEventListener("touchend", onEnd);
|
|
42
|
+
};
|
|
43
|
+
}, [enabled, onNext, onPrev]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when a key event originates from a text-editable element. Typing in a
|
|
47
|
+
* plugin's input (e.g. the Q&A question box) must NOT trigger the global deck
|
|
48
|
+
* shortcuts, otherwise `f`/`o`/space/arrows/Enter drive the deck while you type.
|
|
49
|
+
* Duck-typed (reads `tagName`/`isContentEditable`) so it's unit-testable without a
|
|
50
|
+
* DOM. */
|
|
51
|
+
export function isEditableTarget(target: EventTarget | null): boolean {
|
|
52
|
+
const el = target as (Partial<HTMLElement> & { tagName?: string }) | null;
|
|
53
|
+
if (!el) return false;
|
|
54
|
+
if (el.isContentEditable) return true;
|
|
55
|
+
switch (el.tagName) {
|
|
56
|
+
case "INPUT":
|
|
57
|
+
case "TEXTAREA":
|
|
58
|
+
case "SELECT":
|
|
59
|
+
return true;
|
|
60
|
+
default:
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Keyboard navigation. `onNext`/`onPrev` let the deck intercept for step reveals;
|
|
66
|
+
// they fall back to slide nav. Slide index lives in the deck controller (synced).
|
|
67
|
+
export function useDeckNav(opts: {
|
|
68
|
+
count: number;
|
|
69
|
+
setIndex: (updater: (n: number) => number | number) => void;
|
|
70
|
+
onNext?: () => void;
|
|
71
|
+
onPrev?: () => void;
|
|
72
|
+
onToggleBrand?: () => void;
|
|
73
|
+
onOpenPresenter?: () => void;
|
|
74
|
+
onToggleHelp?: () => void;
|
|
75
|
+
onFullscreen?: () => void;
|
|
76
|
+
onBlur?: () => void;
|
|
77
|
+
onOverview?: () => void;
|
|
78
|
+
onQr?: () => void;
|
|
79
|
+
onDigit?: (key: string) => void;
|
|
80
|
+
}) {
|
|
81
|
+
const {
|
|
82
|
+
count,
|
|
83
|
+
setIndex,
|
|
84
|
+
onNext,
|
|
85
|
+
onPrev,
|
|
86
|
+
onToggleBrand,
|
|
87
|
+
onOpenPresenter,
|
|
88
|
+
onToggleHelp,
|
|
89
|
+
onFullscreen,
|
|
90
|
+
onBlur,
|
|
91
|
+
onOverview,
|
|
92
|
+
onQr,
|
|
93
|
+
onDigit,
|
|
94
|
+
} = opts;
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const next = onNext ?? (() => setIndex((n: number) => Math.min(n + 1, count - 1)));
|
|
98
|
+
const prev = onPrev ?? (() => setIndex((n: number) => Math.max(n - 1, 0)));
|
|
99
|
+
const onKey = (e: KeyboardEvent) => {
|
|
100
|
+
// don't hijack typing in a plugin's input (Q&A box, etc.)
|
|
101
|
+
if (isEditableTarget(e.target)) return;
|
|
102
|
+
switch (e.key) {
|
|
103
|
+
case "ArrowRight":
|
|
104
|
+
case " ":
|
|
105
|
+
case "PageDown":
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
next();
|
|
108
|
+
break;
|
|
109
|
+
case "ArrowLeft":
|
|
110
|
+
case "PageUp":
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
prev();
|
|
113
|
+
break;
|
|
114
|
+
case "Home":
|
|
115
|
+
setIndex(() => 0);
|
|
116
|
+
break;
|
|
117
|
+
case "End":
|
|
118
|
+
setIndex(() => count - 1);
|
|
119
|
+
break;
|
|
120
|
+
case "t":
|
|
121
|
+
onToggleBrand?.();
|
|
122
|
+
break;
|
|
123
|
+
case "p":
|
|
124
|
+
onOpenPresenter?.();
|
|
125
|
+
break;
|
|
126
|
+
case "f":
|
|
127
|
+
onFullscreen?.();
|
|
128
|
+
break;
|
|
129
|
+
case "b":
|
|
130
|
+
onBlur?.();
|
|
131
|
+
break;
|
|
132
|
+
case "o":
|
|
133
|
+
onOverview?.();
|
|
134
|
+
break;
|
|
135
|
+
case "q":
|
|
136
|
+
onQr?.();
|
|
137
|
+
break;
|
|
138
|
+
case "?":
|
|
139
|
+
case "h":
|
|
140
|
+
onToggleHelp?.();
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
if (onDigit && (/^[0-9]$/.test(e.key) || e.key === "Enter" || e.key === "Escape")) onDigit(e.key);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
window.addEventListener("keydown", onKey);
|
|
147
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
148
|
+
}, [count, setIndex, onNext, onPrev, onToggleBrand, onOpenPresenter, onToggleHelp, onFullscreen, onBlur, onOverview, onQr, onDigit]);
|
|
149
|
+
}
|
package/src/slides.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import type { SlideTransition } from "./transitions";
|
|
3
|
+
|
|
4
|
+
// A slide is either a bare component, or a module namespace with optional
|
|
5
|
+
// `notes` (MDX: `export const notes = ...`; TSX: `export const notes`) and an
|
|
6
|
+
// optional per-slide `transition` override (`export const transition = "slide"`).
|
|
7
|
+
export type SlideInput =
|
|
8
|
+
| ComponentType
|
|
9
|
+
| { default: ComponentType; notes?: ReactNode; transition?: SlideTransition };
|
|
10
|
+
|
|
11
|
+
export type NormalizedSlide = { Component: ComponentType; notes?: ReactNode; transition?: SlideTransition };
|
|
12
|
+
|
|
13
|
+
export function normalizeSlides(slides: SlideInput[]): NormalizedSlide[] {
|
|
14
|
+
return slides.map((s) =>
|
|
15
|
+
typeof s === "function"
|
|
16
|
+
? { Component: s }
|
|
17
|
+
: { Component: s.default, notes: s.notes, transition: s.transition },
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SOURCE_ATTR } from "./build/source-attr";
|
|
2
|
+
|
|
3
|
+
/** True when the loaded deck embeds its own recoverable source (the default build), * i.e. `liebstoeckel eject` would work on this file. Lets runtime UI offer an
|
|
4
|
+
* "edit this deck" affordance only when it'll actually succeed (a deck built
|
|
5
|
+
* `--no-inline-package` carries no source). No DOM (capture / SSR) → false. */
|
|
6
|
+
export function hasEmbeddedSource(): boolean {
|
|
7
|
+
if (typeof document === "undefined") return false;
|
|
8
|
+
return !!document.querySelector(`script[${SOURCE_ATTR}]`);
|
|
9
|
+
}
|