@liebstoeckel/engine 0.3.5 → 0.3.6
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/package.json +1 -1
- package/src/Deck.tsx +205 -29
- package/src/HelpOverlay.tsx +9 -9
- package/src/interaction.ts +99 -0
- package/src/nav.ts +46 -45
- package/src/overview.ts +32 -0
package/package.json
CHANGED
package/src/Deck.tsx
CHANGED
|
@@ -24,6 +24,8 @@ import { normalizeSlides, type SlideInput } from "./slides";
|
|
|
24
24
|
import { resolveTransition, mobileTransitionsDisabled, type SlideDirection, type SlideTransition } from "./transitions";
|
|
25
25
|
import type { Theme } from "@liebstoeckel/theme";
|
|
26
26
|
import { useCoarsePointer } from "./useCoarsePointer";
|
|
27
|
+
import { moveSelection, gridCols, type GridDir } from "./overview";
|
|
28
|
+
import type { NavMode } from "./interaction";
|
|
27
29
|
|
|
28
30
|
export type DeckProps = {
|
|
29
31
|
slides: SlideInput[];
|
|
@@ -98,8 +100,34 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
98
100
|
const [overview, setOverview] = useState(false);
|
|
99
101
|
const [qr, setQr] = useState(false);
|
|
100
102
|
const [jump, setJump] = useState("");
|
|
103
|
+
// Terminal "end of deck" screen: advancing past the final slide's last step shows
|
|
104
|
+
// a deliberate end state (like PowerPoint) instead of replaying the last slide's
|
|
105
|
+
// steps. Local to the driving window; cleared on any navigation away.
|
|
106
|
+
const [ended, setEnded] = useState(false);
|
|
107
|
+
// Keyboard selection within the overview grid (seeded to the current slide on open).
|
|
108
|
+
const [sel, setSel] = useState(0);
|
|
101
109
|
const brand = brands[brandIdx % brands.length];
|
|
102
110
|
|
|
111
|
+
// The active interaction layer drives keyboard routing: a modal layer (overview,
|
|
112
|
+
// end) owns its keys so deck nav never leaks through it.
|
|
113
|
+
const mode: NavMode = overview ? "overview" : ended ? "end" : "slide";
|
|
114
|
+
|
|
115
|
+
// Refs mirror the composing state so the keyboard handlers stay stable and never
|
|
116
|
+
// read a stale closure when layers interact.
|
|
117
|
+
const overviewRef = useRef(overview);
|
|
118
|
+
overviewRef.current = overview;
|
|
119
|
+
const selRef = useRef(sel);
|
|
120
|
+
selRef.current = sel;
|
|
121
|
+
const jumpRef = useRef(jump);
|
|
122
|
+
jumpRef.current = jump;
|
|
123
|
+
const stepRef = useRef(step);
|
|
124
|
+
stepRef.current = step;
|
|
125
|
+
const totalRef = useRef(total);
|
|
126
|
+
totalRef.current = total;
|
|
127
|
+
const selectedThumbRef = useRef<HTMLButtonElement | null>(null);
|
|
128
|
+
// True while a Restart's masked crossfade-to-slide-1 is in flight (see onRestart).
|
|
129
|
+
const pendingRestartRef = useRef(false);
|
|
130
|
+
|
|
103
131
|
useEffect(() => {
|
|
104
132
|
document.body.dataset.brand = brand;
|
|
105
133
|
}, [brand]);
|
|
@@ -113,33 +141,121 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
113
141
|
return () => window.removeEventListener("contextmenu", onCtx);
|
|
114
142
|
}, []);
|
|
115
143
|
|
|
144
|
+
// Overview open/close. Opening seeds the selection to the current slide and clears
|
|
145
|
+
// the end state + jump buffer — the modal layers are mutually exclusive.
|
|
146
|
+
const openOverview = useCallback(() => {
|
|
147
|
+
setEnded(false);
|
|
148
|
+
setJump("");
|
|
149
|
+
setSel(indexRef.current);
|
|
150
|
+
setOverview(true);
|
|
151
|
+
}, []);
|
|
152
|
+
const closeOverview = useCallback(() => {
|
|
153
|
+
setOverview(false);
|
|
154
|
+
setJump("");
|
|
155
|
+
}, []);
|
|
156
|
+
const toggleOverview = useCallback(() => {
|
|
157
|
+
if (overviewRef.current) closeOverview();
|
|
158
|
+
else openOverview();
|
|
159
|
+
}, [openOverview, closeOverview]);
|
|
160
|
+
|
|
161
|
+
// Numeric jump is layer-aware: in the overview it moves the live selection (the
|
|
162
|
+
// grid IS the jump surface); otherwise it drives the floating HUD and commits.
|
|
116
163
|
const onDigit = useCallback(
|
|
117
164
|
(key: string) => {
|
|
118
|
-
|
|
165
|
+
if (overviewRef.current) {
|
|
166
|
+
const buffer = (jumpRef.current + key).slice(0, 3);
|
|
167
|
+
setJump(buffer);
|
|
168
|
+
const target = parseInt(buffer, 10) - 1;
|
|
169
|
+
if (!Number.isNaN(target)) setSel(Math.min(Math.max(target, 0), Math.max(count - 1, 0)));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const r = accumulateDigits(jumpRef.current, key);
|
|
119
173
|
setJump(r.buffer);
|
|
120
174
|
if (r.commit != null) ctrl.setIndex(r.commit);
|
|
121
175
|
},
|
|
122
|
-
[
|
|
176
|
+
[count, ctrl],
|
|
123
177
|
);
|
|
124
178
|
|
|
179
|
+
// Overview keyboard: arrows move the selection, Enter opens it.
|
|
180
|
+
const onGridMove = useCallback(
|
|
181
|
+
(dir: GridDir) => {
|
|
182
|
+
setJump("");
|
|
183
|
+
const width = typeof window === "undefined" ? 1280 : window.innerWidth;
|
|
184
|
+
setSel((s) => moveSelection(s, count, gridCols(width), dir));
|
|
185
|
+
},
|
|
186
|
+
[count],
|
|
187
|
+
);
|
|
188
|
+
const onSelect = useCallback(() => {
|
|
189
|
+
ctrl.setIndex(selRef.current);
|
|
190
|
+
closeOverview();
|
|
191
|
+
}, [ctrl, closeOverview]);
|
|
192
|
+
// Esc / back: close the overview, otherwise leave the end screen.
|
|
193
|
+
const onExitModal = useCallback(() => {
|
|
194
|
+
if (overviewRef.current) closeOverview();
|
|
195
|
+
else setEnded(false);
|
|
196
|
+
}, [closeOverview]);
|
|
197
|
+
const onRestart = useCallback(() => {
|
|
198
|
+
// Restart to slide 1 without the last slide flashing into view. The end card is
|
|
199
|
+
// an opaque full-screen layer, so we keep it up (do NOT clear `ended`) while the
|
|
200
|
+
// slide layer crossfades to slide 1 *behind* it — masked — and drop the card only
|
|
201
|
+
// once that exit completes (AnimatePresence onExitComplete). `pendingRestartRef`
|
|
202
|
+
// makes the index-change effect skip its usual end-clear during that window.
|
|
203
|
+
if (indexRef.current === 0) {
|
|
204
|
+
setEnded(false); // already on slide 1 — nothing to mask
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
pendingRestartRef.current = true;
|
|
208
|
+
ctrl.setIndex(0);
|
|
209
|
+
// Safety net: if onExitComplete never fires (interrupted animation), reveal anyway.
|
|
210
|
+
window.setTimeout(() => {
|
|
211
|
+
if (pendingRestartRef.current) {
|
|
212
|
+
pendingRestartRef.current = false;
|
|
213
|
+
setEnded(false);
|
|
214
|
+
}
|
|
215
|
+
}, 700);
|
|
216
|
+
}, [ctrl]);
|
|
217
|
+
|
|
218
|
+
// Advancing past the last slide's final step enters the end screen rather than
|
|
219
|
+
// calling ctrl.next (which would reset the last slide's step to 0 and replay it).
|
|
220
|
+
// Only the deck's driver gets it; a live viewer just follows. In a modal layer
|
|
221
|
+
// routeKey never yields "next", so this only fires while actually presenting.
|
|
222
|
+
const handleNext = useCallback(() => {
|
|
223
|
+
if (canDrive && indexRef.current >= count - 1 && stepRef.current >= totalRef.current) {
|
|
224
|
+
setEnded(true);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
ctrl.next();
|
|
228
|
+
}, [canDrive, count, ctrl]);
|
|
229
|
+
const handlePrev = useCallback(() => ctrl.prev(), [ctrl]);
|
|
230
|
+
// A jump elsewhere (overview select, numeric commit) clears the end state — except
|
|
231
|
+
// during a Restart, where the end card intentionally stays up to mask the crossfade.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!pendingRestartRef.current) setEnded(false);
|
|
234
|
+
}, [index]);
|
|
235
|
+
|
|
125
236
|
useDeckNav({
|
|
126
237
|
count,
|
|
127
238
|
setIndex: ctrl.setIndex,
|
|
128
|
-
|
|
129
|
-
|
|
239
|
+
mode,
|
|
240
|
+
onNext: handleNext,
|
|
241
|
+
onPrev: handlePrev,
|
|
130
242
|
onToggleBrand: brands.length > 1 ? () => setBrandIdx((n) => n + 1) : undefined,
|
|
131
243
|
onOpenPresenter: canDrive ? openPresenter : undefined,
|
|
132
244
|
onToggleHelp: () => setHelp((v) => !v),
|
|
133
245
|
onFullscreen: () => void toggleFullscreen(document.documentElement),
|
|
134
246
|
onBlur: () => setBlurred((v) => !v),
|
|
135
|
-
onOverview: canDrive ?
|
|
247
|
+
onOverview: canDrive ? toggleOverview : undefined,
|
|
136
248
|
onQr: () => setQr((v) => !v),
|
|
137
249
|
onDigit,
|
|
250
|
+
onGridMove,
|
|
251
|
+
onSelect,
|
|
252
|
+
onExitModal,
|
|
253
|
+
onRestart,
|
|
138
254
|
});
|
|
139
255
|
|
|
140
|
-
// Touch nav
|
|
141
|
-
//
|
|
142
|
-
useTouchNav({ enabled: role !== "viewer", onNext:
|
|
256
|
+
// Touch nav drives the deck only while presenting — suppressed under any modal
|
|
257
|
+
// layer so a swipe can't move the slide behind the overview / end screen.
|
|
258
|
+
useTouchNav({ enabled: role !== "viewer" && !overview && !ended, onNext: handleNext, onPrev: handlePrev });
|
|
143
259
|
|
|
144
260
|
const Current = norm[index]?.Component ?? (() => null);
|
|
145
261
|
|
|
@@ -160,6 +276,12 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
160
276
|
: (norm[index]?.transition ?? deckTransition);
|
|
161
277
|
const slideTransition = resolveTransition(requested, !!reduceMotion);
|
|
162
278
|
|
|
279
|
+
// Keep the keyboard-selected overview thumbnail in view as you arrow through a
|
|
280
|
+
// long deck.
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (overview) selectedThumbRef.current?.scrollIntoView({ block: "nearest", behavior: reduceMotion ? "auto" : "smooth" });
|
|
283
|
+
}, [sel, overview, reduceMotion]);
|
|
284
|
+
|
|
163
285
|
return (
|
|
164
286
|
<MDXProvider components={mdxComponents}>
|
|
165
287
|
<PersistentProvider>
|
|
@@ -169,7 +291,17 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
169
291
|
<div className="relative h-dvh w-screen overflow-hidden bg-bg">
|
|
170
292
|
<ScaledStage className="absolute inset-0">
|
|
171
293
|
<div data-deck-root className="absolute inset-0">
|
|
172
|
-
<AnimatePresence
|
|
294
|
+
<AnimatePresence
|
|
295
|
+
custom={direction}
|
|
296
|
+
onExitComplete={() => {
|
|
297
|
+
// Restart kept the end card up to mask the crossfade to slide 1; the
|
|
298
|
+
// exiting last slide is now gone, so it's safe to drop the card.
|
|
299
|
+
if (pendingRestartRef.current) {
|
|
300
|
+
pendingRestartRef.current = false;
|
|
301
|
+
setEnded(false);
|
|
302
|
+
}
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
173
305
|
<motion.div
|
|
174
306
|
key={index}
|
|
175
307
|
custom={direction}
|
|
@@ -194,20 +326,6 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
194
326
|
slide, below chrome; non-interactive (live only, see ADR 0021) */}
|
|
195
327
|
<PluginOverlays />
|
|
196
328
|
|
|
197
|
-
{/* jump-to-number buffer */}
|
|
198
|
-
<AnimatePresence>
|
|
199
|
-
{jump && (
|
|
200
|
-
<motion.div
|
|
201
|
-
initial={{ opacity: 0, scale: 0.9 }}
|
|
202
|
-
animate={{ opacity: 1, scale: 1 }}
|
|
203
|
-
exit={{ opacity: 0 }}
|
|
204
|
-
className="absolute left-1/2 top-8 -translate-x-1/2 rounded-xl border border-border bg-surface/80 px-5 py-2 font-mono text-2xl text-text backdrop-blur"
|
|
205
|
-
>
|
|
206
|
-
→ {jump}
|
|
207
|
-
</motion.div>
|
|
208
|
-
)}
|
|
209
|
-
</AnimatePresence>
|
|
210
|
-
|
|
211
329
|
<HelpOverlay open={help} onClose={() => setHelp(false)} showBrand={brands.length > 1} role={role} ejectable={ejectable} />
|
|
212
330
|
<PortraitHint />
|
|
213
331
|
</div>
|
|
@@ -239,21 +357,36 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
239
357
|
{overview && (
|
|
240
358
|
<motion.div
|
|
241
359
|
className="absolute inset-0 z-40 overflow-auto bg-bg/95 p-[4%] backdrop-blur-xl"
|
|
242
|
-
|
|
360
|
+
// Mount already opaque (no fade-in): opening from the end screen, the
|
|
361
|
+
// overview must cover the last slide *immediately* while the end card
|
|
362
|
+
// fades out on top, otherwise the slide flashes through the gap.
|
|
363
|
+
initial={{ opacity: 1 }}
|
|
243
364
|
animate={{ opacity: 1 }}
|
|
244
365
|
exit={{ opacity: 0 }}
|
|
366
|
+
transition={reduceMotion ? { duration: 0 } : undefined}
|
|
245
367
|
>
|
|
246
|
-
<div className="mb-6
|
|
368
|
+
<div className="mb-6 flex items-baseline justify-between gap-4">
|
|
369
|
+
<div className="font-mono text-sm uppercase tracking-[0.3em] text-muted">
|
|
370
|
+
Overview · ←↑↓→ move · Enter open · or type a number
|
|
371
|
+
</div>
|
|
372
|
+
{jump && <div className="font-mono text-sm text-text">→ {jump}</div>}
|
|
373
|
+
</div>
|
|
247
374
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 lg:gap-5">
|
|
248
375
|
{norm.map((s, i) => (
|
|
249
376
|
<button
|
|
250
377
|
key={i}
|
|
378
|
+
ref={i === sel ? selectedThumbRef : undefined}
|
|
251
379
|
onClick={() => {
|
|
252
380
|
ctrl.setIndex(i);
|
|
253
|
-
|
|
381
|
+
closeOverview();
|
|
254
382
|
}}
|
|
255
|
-
|
|
256
|
-
|
|
383
|
+
aria-current={i === index ? "true" : undefined}
|
|
384
|
+
className={`relative aspect-video overflow-hidden rounded-xl border text-left outline-none transition ${
|
|
385
|
+
i === sel
|
|
386
|
+
? "border-primary ring-2 ring-primary"
|
|
387
|
+
: i === index
|
|
388
|
+
? "border-primary"
|
|
389
|
+
: "border-border hover:border-text"
|
|
257
390
|
}`}
|
|
258
391
|
>
|
|
259
392
|
<DeckThumb Component={s.Component} src={thumbs?.get(i)} alt={`Slide ${i + 1}`} />
|
|
@@ -265,6 +398,49 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
265
398
|
)}
|
|
266
399
|
</AnimatePresence>
|
|
267
400
|
|
|
401
|
+
{/* terminal end-of-deck card (advancing past the last slide) — a calm,
|
|
402
|
+
deliberate stop, NOT a wrap; covers the slide + ambient motion. Tapping the
|
|
403
|
+
backdrop goes back; the action row offers Back / Overview / Restart. */}
|
|
404
|
+
<AnimatePresence>
|
|
405
|
+
{ended && (
|
|
406
|
+
<motion.div
|
|
407
|
+
className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-6 bg-bg text-center"
|
|
408
|
+
initial={{ opacity: 0 }}
|
|
409
|
+
animate={{ opacity: 1 }}
|
|
410
|
+
exit={{ opacity: 0 }}
|
|
411
|
+
transition={reduceMotion ? { duration: 0 } : { duration: 0.3 }}
|
|
412
|
+
onClick={() => setEnded(false)}
|
|
413
|
+
>
|
|
414
|
+
<div className="space-y-2">
|
|
415
|
+
<div className="font-mono text-sm uppercase tracking-[0.3em] text-muted">End of deck</div>
|
|
416
|
+
<div className="font-mono text-xs text-muted/60">{count} {count === 1 ? "slide" : "slides"}</div>
|
|
417
|
+
</div>
|
|
418
|
+
<div className="flex flex-wrap items-center justify-center gap-3" onClick={(e) => e.stopPropagation()}>
|
|
419
|
+
<button onClick={() => setEnded(false)} className="rounded-lg border border-border px-4 py-2 font-mono text-sm text-text transition hover:border-text">← Back</button>
|
|
420
|
+
<button onClick={openOverview} className="rounded-lg border border-border px-4 py-2 font-mono text-sm text-text transition hover:border-text">Overview</button>
|
|
421
|
+
<button onClick={onRestart} className="rounded-lg border border-border px-4 py-2 font-mono text-sm text-text transition hover:border-text">↺ Restart</button>
|
|
422
|
+
</div>
|
|
423
|
+
<div className="font-mono text-[0.7rem] text-muted/50">← back · O overview · R restart · type a number to jump</div>
|
|
424
|
+
</motion.div>
|
|
425
|
+
)}
|
|
426
|
+
</AnimatePresence>
|
|
427
|
+
|
|
428
|
+
{/* numeric-jump HUD — a single top layer above EVERY overlay (device scale), so
|
|
429
|
+
typing a number reads correctly in slide and end modes. In the overview the
|
|
430
|
+
buffer renders in its header instead (the grid is the jump surface). */}
|
|
431
|
+
<AnimatePresence>
|
|
432
|
+
{jump && !overview && (
|
|
433
|
+
<motion.div
|
|
434
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
435
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
436
|
+
exit={{ opacity: 0 }}
|
|
437
|
+
className="pointer-events-none absolute left-1/2 top-8 z-[60] -translate-x-1/2 rounded-xl border border-border bg-surface/80 px-5 py-2 font-mono text-2xl text-text backdrop-blur"
|
|
438
|
+
>
|
|
439
|
+
→ {jump}
|
|
440
|
+
</motion.div>
|
|
441
|
+
)}
|
|
442
|
+
</AnimatePresence>
|
|
443
|
+
|
|
268
444
|
{/* chrome at DEVICE scale, pinned to the real viewport (outside the scaled
|
|
269
445
|
canvas) so the help + plugin buttons stay tappable on a phone. On touch
|
|
270
446
|
the help affordance becomes a ⋮ action menu (fullscreen, overview, …). */}
|
|
@@ -276,7 +452,7 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
|
|
|
276
452
|
canDrive={canDrive}
|
|
277
453
|
viewerUrl={liveCtx?.viewerUrl}
|
|
278
454
|
onHelp={() => setHelp(true)}
|
|
279
|
-
onOverview={
|
|
455
|
+
onOverview={toggleOverview}
|
|
280
456
|
onQr={() => setQr((v) => !v)}
|
|
281
457
|
/>
|
|
282
458
|
</div>
|
package/src/HelpOverlay.tsx
CHANGED
|
@@ -10,7 +10,7 @@ const EDIT_DOCS_URL = "https://docs.liebstoeckel.app/guides/editing-a-built-deck
|
|
|
10
10
|
function Kbd({ children, dim }: { children: string; dim?: boolean }) {
|
|
11
11
|
return (
|
|
12
12
|
<kbd
|
|
13
|
-
className={`inline-flex min-w-[1.
|
|
13
|
+
className={`inline-flex min-w-[1.6rem] items-center justify-center rounded-md border px-1.5 py-0.5 font-mono text-xs shadow-sm ${
|
|
14
14
|
dim ? "border-border/50 bg-bg/40 text-muted" : "border-border bg-bg text-text"
|
|
15
15
|
}`}
|
|
16
16
|
>
|
|
@@ -83,10 +83,10 @@ export function HelpOverlay({
|
|
|
83
83
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
84
84
|
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
85
85
|
transition={{ type: "spring", stiffness: 260, damping: 24 }}
|
|
86
|
-
className="relative w-[
|
|
86
|
+
className="relative max-h-[88%] w-[400px] overflow-auto rounded-2xl border border-border bg-surface/90 p-6 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
|
|
87
87
|
>
|
|
88
|
-
<div className="mb-
|
|
89
|
-
<span className="font-heading text-
|
|
88
|
+
<div className="mb-4 flex items-baseline justify-between">
|
|
89
|
+
<span className="font-heading text-xl font-semibold text-text">Shortcuts</span>
|
|
90
90
|
{role ? (
|
|
91
91
|
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-accent">
|
|
92
92
|
● live · {role}
|
|
@@ -101,7 +101,7 @@ export function HelpOverlay({
|
|
|
101
101
|
navigation. You can still interact (e.g. vote).
|
|
102
102
|
</p>
|
|
103
103
|
)}
|
|
104
|
-
<ul className="space-y-
|
|
104
|
+
<ul className="space-y-1.5">
|
|
105
105
|
{shortcuts.map((s) => {
|
|
106
106
|
const disabled = isViewer && s.presenterOnly;
|
|
107
107
|
return (
|
|
@@ -110,7 +110,7 @@ export function HelpOverlay({
|
|
|
110
110
|
className={`flex items-center justify-between gap-4 ${disabled ? "opacity-40" : ""}`}
|
|
111
111
|
title={disabled ? "Presenter only" : undefined}
|
|
112
112
|
>
|
|
113
|
-
<span className="font-body text-
|
|
113
|
+
<span className="font-body text-sm text-text/85">
|
|
114
114
|
{s.label}
|
|
115
115
|
{disabled && <span className="ml-2 font-mono text-[10px] uppercase tracking-wider text-muted">presenter</span>}
|
|
116
116
|
</span>
|
|
@@ -127,8 +127,8 @@ export function HelpOverlay({
|
|
|
127
127
|
})}
|
|
128
128
|
</ul>
|
|
129
129
|
{ejectable && (
|
|
130
|
-
<div className="mt-
|
|
131
|
-
<p className="font-body text-
|
|
130
|
+
<div className="mt-4 border-t border-border pt-3">
|
|
131
|
+
<p className="font-body text-[13px] text-text/80">
|
|
132
132
|
This deck embeds its own source, recover it with{" "}
|
|
133
133
|
<code className="rounded bg-bg/60 px-1.5 py-0.5 font-mono text-[13px] text-text">
|
|
134
134
|
liebstoeckel eject
|
|
@@ -145,7 +145,7 @@ export function HelpOverlay({
|
|
|
145
145
|
</p>
|
|
146
146
|
</div>
|
|
147
147
|
)}
|
|
148
|
-
<div className="mt-
|
|
148
|
+
<div className="mt-4 border-t border-border pt-3 text-center font-mono text-[11px] uppercase tracking-[0.25em] text-muted">
|
|
149
149
|
right-click anywhere to toggle
|
|
150
150
|
</div>
|
|
151
151
|
</motion.div>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Pure keyboard routing for the deck. Given the active interaction *layer* and a
|
|
2
|
+
// key, decide the action — so a modal layer (overview, end) captures its keys and
|
|
3
|
+
// deck navigation never leaks through it. No DOM; unit-testable (key × layer → action).
|
|
4
|
+
import type { GridDir } from "./overview";
|
|
5
|
+
|
|
6
|
+
export type NavMode = "slide" | "overview" | "end";
|
|
7
|
+
|
|
8
|
+
export type NavAction =
|
|
9
|
+
| "next"
|
|
10
|
+
| "prev"
|
|
11
|
+
| "first"
|
|
12
|
+
| "last"
|
|
13
|
+
| "select"
|
|
14
|
+
| "exitModal"
|
|
15
|
+
| "restart"
|
|
16
|
+
| "toggleBrand"
|
|
17
|
+
| "presenter"
|
|
18
|
+
| "fullscreen"
|
|
19
|
+
| "blur"
|
|
20
|
+
| "overview"
|
|
21
|
+
| "qr"
|
|
22
|
+
| "help"
|
|
23
|
+
| { grid: GridDir }
|
|
24
|
+
| { digit: string }
|
|
25
|
+
| null;
|
|
26
|
+
|
|
27
|
+
const ARROW_GRID: Record<string, GridDir> = {
|
|
28
|
+
ArrowLeft: "left",
|
|
29
|
+
ArrowRight: "right",
|
|
30
|
+
ArrowUp: "up",
|
|
31
|
+
ArrowDown: "down",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const isDigit = (key: string) => /^[0-9]$/.test(key);
|
|
35
|
+
|
|
36
|
+
/** Keys whose browser default (page scroll) we suppress when they map to an action. */
|
|
37
|
+
export function preventsDefault(key: string): boolean {
|
|
38
|
+
return key in ARROW_GRID || key === " " || key === "PageDown" || key === "PageUp";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function routeKey(mode: NavMode, key: string): NavAction {
|
|
42
|
+
// Overview: a modal grid picker. Arrows move the selection, Enter opens it,
|
|
43
|
+
// digits build a jump, Esc/o close. Everything else is swallowed (no deck nav).
|
|
44
|
+
if (mode === "overview") {
|
|
45
|
+
if (key in ARROW_GRID) return { grid: ARROW_GRID[key] };
|
|
46
|
+
if (key === "Enter") return "select";
|
|
47
|
+
if (key === "Escape") return "exitModal";
|
|
48
|
+
if (key === "o") return "overview";
|
|
49
|
+
if (isDigit(key)) return { digit: key };
|
|
50
|
+
if (key === "f") return "fullscreen";
|
|
51
|
+
if (key === "q") return "qr";
|
|
52
|
+
if (key === "?" || key === "h") return "help";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// End screen: a terminal card. ←/Esc/Backspace go back, o opens the overview,
|
|
56
|
+
// r/Home restart, digits/Enter jump. Forward keys are swallowed (no replay).
|
|
57
|
+
if (mode === "end") {
|
|
58
|
+
if (key === "ArrowLeft" || key === "Escape" || key === "Backspace") return "exitModal";
|
|
59
|
+
if (key === "o") return "overview";
|
|
60
|
+
if (key === "r" || key === "Home") return "restart";
|
|
61
|
+
if (isDigit(key) || key === "Enter") return { digit: key };
|
|
62
|
+
if (key === "f") return "fullscreen";
|
|
63
|
+
if (key === "q") return "qr";
|
|
64
|
+
if (key === "?" || key === "h") return "help";
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// Slide (default) — the normal presenting controls.
|
|
68
|
+
switch (key) {
|
|
69
|
+
case "ArrowRight":
|
|
70
|
+
case " ":
|
|
71
|
+
case "PageDown":
|
|
72
|
+
return "next";
|
|
73
|
+
case "ArrowLeft":
|
|
74
|
+
case "PageUp":
|
|
75
|
+
return "prev";
|
|
76
|
+
case "Home":
|
|
77
|
+
return "first";
|
|
78
|
+
case "End":
|
|
79
|
+
return "last";
|
|
80
|
+
case "t":
|
|
81
|
+
return "toggleBrand";
|
|
82
|
+
case "p":
|
|
83
|
+
return "presenter";
|
|
84
|
+
case "f":
|
|
85
|
+
return "fullscreen";
|
|
86
|
+
case "b":
|
|
87
|
+
return "blur";
|
|
88
|
+
case "o":
|
|
89
|
+
return "overview";
|
|
90
|
+
case "q":
|
|
91
|
+
return "qr";
|
|
92
|
+
case "?":
|
|
93
|
+
case "h":
|
|
94
|
+
return "help";
|
|
95
|
+
default:
|
|
96
|
+
if (isDigit(key) || key === "Enter" || key === "Escape") return { digit: key };
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/nav.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { resolveTouchGesture, NO_NAV_SELECTOR } from "./mobile";
|
|
3
|
+
import { routeKey, preventsDefault, type NavMode } from "./interaction";
|
|
4
|
+
import type { GridDir } from "./overview";
|
|
3
5
|
|
|
4
6
|
/** Touch navigation: horizontal swipe + edge tap-zones, resolved by the pure
|
|
5
7
|
* `resolveTouchGesture`. Reuses `onNext`/`onPrev` so step-reveals still run before
|
|
@@ -62,11 +64,15 @@ export function isEditableTarget(target: EventTarget | null): boolean {
|
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
// Keyboard navigation
|
|
66
|
-
//
|
|
67
|
+
// Keyboard navigation, routed by the active interaction layer (`mode`). In a modal
|
|
68
|
+
// layer (overview, end) the layer owns its keys and deck nav never leaks through —
|
|
69
|
+
// the routing is the pure `routeKey` (key × mode → action); this hook just dispatches.
|
|
70
|
+
// `onNext`/`onPrev` let the deck intercept for step reveals; they fall back to slide nav.
|
|
67
71
|
export function useDeckNav(opts: {
|
|
68
72
|
count: number;
|
|
69
73
|
setIndex: (updater: (n: number) => number | number) => void;
|
|
74
|
+
/** Active interaction layer. Defaults to "slide". */
|
|
75
|
+
mode?: NavMode;
|
|
70
76
|
onNext?: () => void;
|
|
71
77
|
onPrev?: () => void;
|
|
72
78
|
onToggleBrand?: () => void;
|
|
@@ -77,10 +83,19 @@ export function useDeckNav(opts: {
|
|
|
77
83
|
onOverview?: () => void;
|
|
78
84
|
onQr?: () => void;
|
|
79
85
|
onDigit?: (key: string) => void;
|
|
86
|
+
/** Overview grid selection move (← → ↑ ↓ while the overview is open). */
|
|
87
|
+
onGridMove?: (dir: GridDir) => void;
|
|
88
|
+
/** Confirm the overview selection (Enter). */
|
|
89
|
+
onSelect?: () => void;
|
|
90
|
+
/** Close the top modal / go back (Esc, or ← on the end screen). */
|
|
91
|
+
onExitModal?: () => void;
|
|
92
|
+
/** Restart to the first slide (R / Home on the end screen). */
|
|
93
|
+
onRestart?: () => void;
|
|
80
94
|
}) {
|
|
81
95
|
const {
|
|
82
96
|
count,
|
|
83
97
|
setIndex,
|
|
98
|
+
mode = "slide",
|
|
84
99
|
onNext,
|
|
85
100
|
onPrev,
|
|
86
101
|
onToggleBrand,
|
|
@@ -91,6 +106,10 @@ export function useDeckNav(opts: {
|
|
|
91
106
|
onOverview,
|
|
92
107
|
onQr,
|
|
93
108
|
onDigit,
|
|
109
|
+
onGridMove,
|
|
110
|
+
onSelect,
|
|
111
|
+
onExitModal,
|
|
112
|
+
onRestart,
|
|
94
113
|
} = opts;
|
|
95
114
|
|
|
96
115
|
useEffect(() => {
|
|
@@ -99,51 +118,33 @@ export function useDeckNav(opts: {
|
|
|
99
118
|
const onKey = (e: KeyboardEvent) => {
|
|
100
119
|
// don't hijack typing in a plugin's input (Q&A box, etc.)
|
|
101
120
|
if (isEditableTarget(e.target)) return;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
case "
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
case "
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
case "
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
case "
|
|
124
|
-
|
|
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);
|
|
121
|
+
const action = routeKey(mode, e.key);
|
|
122
|
+
if (action == null) return;
|
|
123
|
+
if (preventsDefault(e.key)) e.preventDefault();
|
|
124
|
+
if (typeof action === "object") {
|
|
125
|
+
if ("grid" in action) onGridMove?.(action.grid);
|
|
126
|
+
else onDigit?.(action.digit);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
switch (action) {
|
|
130
|
+
case "next": next(); break;
|
|
131
|
+
case "prev": prev(); break;
|
|
132
|
+
case "first": setIndex(() => 0); break;
|
|
133
|
+
case "last": setIndex(() => count - 1); break;
|
|
134
|
+
case "select": onSelect?.(); break;
|
|
135
|
+
case "exitModal": onExitModal?.(); break;
|
|
136
|
+
case "restart": onRestart?.(); break;
|
|
137
|
+
case "toggleBrand": onToggleBrand?.(); break;
|
|
138
|
+
case "presenter": onOpenPresenter?.(); break;
|
|
139
|
+
case "fullscreen": onFullscreen?.(); break;
|
|
140
|
+
case "blur": onBlur?.(); break;
|
|
141
|
+
case "overview": onOverview?.(); break;
|
|
142
|
+
case "qr": onQr?.(); break;
|
|
143
|
+
case "help": onToggleHelp?.(); break;
|
|
144
144
|
}
|
|
145
145
|
};
|
|
146
146
|
window.addEventListener("keydown", onKey);
|
|
147
147
|
return () => window.removeEventListener("keydown", onKey);
|
|
148
|
-
|
|
148
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
149
|
+
}, [count, setIndex, mode, onNext, onPrev, onToggleBrand, onOpenPresenter, onToggleHelp, onFullscreen, onBlur, onOverview, onQr, onDigit, onGridMove, onSelect, onExitModal, onRestart]);
|
|
149
150
|
}
|
package/src/overview.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Pure helpers for the overview grid's keyboard selection. No DOM — unit-testable.
|
|
2
|
+
|
|
3
|
+
export type GridDir = "left" | "right" | "up" | "down";
|
|
4
|
+
|
|
5
|
+
/** Columns the overview grid renders at a given viewport width — mirrors the
|
|
6
|
+
* Tailwind breakpoints on the grid (`grid-cols-2` base, `sm:grid-cols-3`,
|
|
7
|
+
* `lg:grid-cols-4`). Used to make ↑/↓ move by a row. */
|
|
8
|
+
export function gridCols(width: number): number {
|
|
9
|
+
if (width >= 1024) return 4;
|
|
10
|
+
if (width >= 640) return 3;
|
|
11
|
+
return 2;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Move a 0-based selection within a `count`-item, `cols`-wide grid by an arrow
|
|
15
|
+
* direction, clamped to [0, count-1]. ←/→ step linearly (so you can traverse the
|
|
16
|
+
* whole deck, crossing row boundaries); ↑/↓ step by a full row and stop at the
|
|
17
|
+
* edges rather than wrapping. */
|
|
18
|
+
export function moveSelection(sel: number, count: number, cols: number, dir: GridDir): number {
|
|
19
|
+
if (count <= 0) return 0;
|
|
20
|
+
const c = Math.max(1, cols);
|
|
21
|
+
const clamp = (n: number) => Math.min(Math.max(n, 0), count - 1);
|
|
22
|
+
switch (dir) {
|
|
23
|
+
case "left":
|
|
24
|
+
return clamp(sel - 1);
|
|
25
|
+
case "right":
|
|
26
|
+
return clamp(sel + 1);
|
|
27
|
+
case "up":
|
|
28
|
+
return sel - c >= 0 ? sel - c : sel;
|
|
29
|
+
case "down":
|
|
30
|
+
return sel + c <= count - 1 ? sel + c : sel;
|
|
31
|
+
}
|
|
32
|
+
}
|