@honeydeck/honeydeck 0.9.0 → 0.11.0
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/docs/configuration.md +3 -2
- package/docs/deeper-dive.md +3 -3
- package/docs/mobile.md +2 -8
- package/docs/navigation.md +2 -2
- package/docs/presenter-mode.md +4 -9
- package/docs/transitions.md +33 -0
- package/package.json +1 -1
- package/src/runtime/Deck.tsx +58 -13
- package/src/runtime/SPEC.md +5 -3
- package/src/runtime/components/NavBar.tsx +8 -6
- package/src/runtime/magicTransition.ts +367 -0
- package/src/runtime/views/PresenterView.tsx +142 -234
- package/src/runtime/views/SPEC.md +3 -5
- package/src/theme/base.css +14 -0
- package/src/vite-plugin/SPEC.md +2 -2
package/docs/configuration.md
CHANGED
|
@@ -16,7 +16,7 @@ Defined in the first frontmatter block of the deck entry file (before any slide
|
|
|
16
16
|
| `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Browser color mode |
|
|
17
17
|
| `pdfColorMode` | `"light" \| "dark"` | unset | Optional PDF color mode; when unset, falls back to pinned `colorMode`, then `light` |
|
|
18
18
|
| `pdfSteps` | `"final" \| "all"` | `"final"` | PDF includes all steps or final state |
|
|
19
|
-
| `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, or custom CSS name) |
|
|
19
|
+
| `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, `magic`, or custom CSS name) |
|
|
20
20
|
| `transitionDuration` | `number` | `200` | Default slide transition duration in milliseconds |
|
|
21
21
|
| `transitionEasing` | `string` | `ease` | Default slide transition timing function |
|
|
22
22
|
| `magicCodeDuration` | `number` | `800` | Default Magic Code animation duration in milliseconds |
|
|
@@ -53,11 +53,12 @@ A subtle named `fade` transition is applied between slides by default:
|
|
|
53
53
|
```yaml
|
|
54
54
|
transition: fade # default
|
|
55
55
|
transition: none # disable
|
|
56
|
+
transition: magic # explicit data-magic-id FLIP movement
|
|
56
57
|
```
|
|
57
58
|
|
|
58
59
|
Legacy booleans still work: `transition: true` maps to `fade`, and `transition: false` maps to `none`.
|
|
59
60
|
|
|
60
|
-
For built-ins, custom CSS transitions, duration, and easing, see [Transitions](transitions.md).
|
|
61
|
+
For built-ins, magic `data-magic-id` matching, custom CSS transitions, duration, and easing, see [Transitions](transitions.md).
|
|
61
62
|
|
|
62
63
|
### Magic Code Duration
|
|
63
64
|
|
package/docs/deeper-dive.md
CHANGED
|
@@ -241,7 +241,7 @@ Keyboard shortcuts:
|
|
|
241
241
|
| `down` / `s` | Next slide |
|
|
242
242
|
| `up` / `w` | Previous slide |
|
|
243
243
|
| `o` | Toggle overview mode |
|
|
244
|
-
| `p` | Open presenter mode in the current tab |
|
|
244
|
+
| `p` | Open presenter mode in the current tab; on mobile, direct presenter URLs show an unsupported hint |
|
|
245
245
|
| `f` | Toggle fullscreen |
|
|
246
246
|
| `Escape` | Exit overview or fullscreen |
|
|
247
247
|
|
|
@@ -256,9 +256,9 @@ URL state preserves slide and step position:
|
|
|
256
256
|
/#/components components tab
|
|
257
257
|
```
|
|
258
258
|
|
|
259
|
-
On touch devices, swipe left/right for next/previous step and swipe up/down for next/previous slide. The navigation bar is always visible on touch devices.
|
|
259
|
+
On touch devices, swipe left/right for next/previous step and swipe up/down for next/previous slide. The navigation bar is always visible on touch devices and hides the presenter-mode button on mobile.
|
|
260
260
|
|
|
261
|
-
|
|
261
|
+
On `md` and wider screens, presenter mode opens with `p` and includes:
|
|
262
262
|
|
|
263
263
|
- current slide and next slide preview
|
|
264
264
|
- speaker notes from `<Notes>`
|
package/docs/mobile.md
CHANGED
|
@@ -13,7 +13,7 @@ Honeydeck works on phones and tablets, but mobile presentation mode behaves diff
|
|
|
13
13
|
| Text selection | Slide text can be selected normally | Slide text selection is off by default; use the nav bar toggle when you need it |
|
|
14
14
|
| Zoom | Browser/page zoom or normal browser controls | Honeydeck-controlled slide zoom with pinch, up to `5x` |
|
|
15
15
|
| Overview | Keyboard selection and mouse clicks | Responsive fixed two-column grid |
|
|
16
|
-
| Presenter mode | Current slide, next preview, notes, clock, timer, actions;
|
|
16
|
+
| Presenter mode | Current slide, next preview, notes, clock, timer, actions | Not supported; direct presenter URLs show a full-page hint with a button back to the slide |
|
|
17
17
|
|
|
18
18
|
## Tap zones
|
|
19
19
|
|
|
@@ -93,13 +93,7 @@ Touch scroll belongs to the overview grid and never navigates slides. Tap a slid
|
|
|
93
93
|
|
|
94
94
|
## Presenter mode on mobile
|
|
95
95
|
|
|
96
|
-
Presenter mode
|
|
97
|
-
|
|
98
|
-
- Current slide preview
|
|
99
|
-
- Speaker notes
|
|
100
|
-
- Navigation buttons
|
|
101
|
-
|
|
102
|
-
The desktop next-slide preview is hidden on mobile because there is not enough space.
|
|
96
|
+
Presenter mode is not supported below Tailwind's `md` breakpoint. The mobile navigation bar does not offer the presenter-mode button. If someone opens a `/#/presenter/...` URL directly on mobile, Honeydeck shows a full-page hint and a **Go to slide** button that returns to the same slide and step in audience view.
|
|
103
97
|
|
|
104
98
|
## Tips for mobile-friendly decks
|
|
105
99
|
|
package/docs/navigation.md
CHANGED
|
@@ -29,7 +29,7 @@ Boundary behavior:
|
|
|
29
29
|
- Hidden by default for clean presentation.
|
|
30
30
|
- Appears on cursor hover near bottom edge.
|
|
31
31
|
- Positioned bottom-left.
|
|
32
|
-
- Contains: current slide number, navigation arrows, overview button, presenter button, fullscreen button, color mode switch (system / dark / light).
|
|
32
|
+
- Contains: current slide number, navigation arrows, overview button, presenter button on `md` and wider screens, fullscreen button, color mode switch (system / dark / light).
|
|
33
33
|
- Built-in controls use `lucide-react` icons, imported from the suffixed `...Icon` exports (for example `ChevronLeftIcon`), not inline SVG paths or unsuffixed aliases.
|
|
34
34
|
|
|
35
35
|
When `showSlideNumbers: true`, the current slide number is also shown as a single viewer overlay aligned to the bottom-right corner of the slide canvas. It is not part of individual slide content, so it does not animate during slide transitions. It renders as themed foreground text without a background container.
|
|
@@ -60,7 +60,7 @@ Mobile uses touch-specific controls. See the full [Mobile and Touch](mobile.md)
|
|
|
60
60
|
- Swipe down → previous slide
|
|
61
61
|
- Pinch zoom enlarges slide content up to `5x`; dragging pans while zoomed.
|
|
62
62
|
- Slide text selection is off by default on touch devices; the navigation bar includes a toggle to enable selection when needed.
|
|
63
|
-
- Presenter mode on mobile
|
|
63
|
+
- Presenter mode is not supported on mobile. Direct presenter URLs show a full-page hint with a button back to the same slide and step in audience view.
|
|
64
64
|
|
|
65
65
|
## Overview Mode
|
|
66
66
|
|
package/docs/presenter-mode.md
CHANGED
|
@@ -34,7 +34,7 @@ Includes:
|
|
|
34
34
|
- Color mode toggle that also updates audience views and cast receivers
|
|
35
35
|
- Blank screen toggle (`b`) that makes audience and cast views black while presenter mode shows a `Screen blanked (b)` indicator
|
|
36
36
|
- Button to cast the audience view to a secondary display when supported; the same control becomes a stop button while casting. Unsupported browsers show a visibly disabled-looking button with hover/accessible text explaining why casting is unavailable.
|
|
37
|
-
-
|
|
37
|
+
- Desktop keyboard shortcuts that move through the timeline while staying in presenter mode.
|
|
38
38
|
|
|
39
39
|
## Speaker Notes
|
|
40
40
|
|
|
@@ -64,7 +64,7 @@ Content here.
|
|
|
64
64
|
## Opening Presenter Mode
|
|
65
65
|
|
|
66
66
|
- Keyboard shortcut `p` from normal presentation → opens presenter mode in the current tab.
|
|
67
|
-
- Navigation controls button in normal presentation.
|
|
67
|
+
- Navigation controls button in normal presentation on `md` and wider screens.
|
|
68
68
|
- Direct URL: `/#/presenter/1/0`
|
|
69
69
|
|
|
70
70
|
Pressing `p` opens presenter mode in the **current tab**. In presenter mode, press `p` or `Escape` to return to the audience slide view at the same slide and step.
|
|
@@ -80,7 +80,7 @@ Presenter mode uses the same timeline keyboard shortcuts as normal slide view:
|
|
|
80
80
|
| `↓` / `s` | Next slide |
|
|
81
81
|
| `↑` / `w` | Previous slide |
|
|
82
82
|
|
|
83
|
-
Keyboard navigation
|
|
83
|
+
Keyboard navigation keeps the URL under `/#/presenter/...`. Presenter controls use `lucide-react` icons imported from suffixed `...Icon` exports.
|
|
84
84
|
|
|
85
85
|
The `Next` preview shows the next timeline state, not just the next slide. Reveals and code step-through highlights therefore preview exactly what the audience will see after the next forward navigation.
|
|
86
86
|
|
|
@@ -107,9 +107,4 @@ This supports the common laptop/projector setup.
|
|
|
107
107
|
|
|
108
108
|
## Mobile Presenter Mode
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
- Current slide
|
|
112
|
-
- Notes
|
|
113
|
-
- Navigation buttons
|
|
114
|
-
|
|
115
|
-
No next slide preview (space constraint).
|
|
110
|
+
Presenter mode is not supported below Tailwind's `md` breakpoint. The navigation bar hides the presenter-mode button on mobile. If someone opens a `/#/presenter/...` URL on mobile, Honeydeck shows a full-page hint and a **Go to slide** button that returns to the same slide and step in audience view.
|
package/docs/transitions.md
CHANGED
|
@@ -25,6 +25,7 @@ Built-in transition names:
|
|
|
25
25
|
- `fade` — default crossfade
|
|
26
26
|
- `none` — no slide transition
|
|
27
27
|
- `slide-left` — horizontal slide, reverse-aware when navigating backward
|
|
28
|
+
- `magic` — explicit `data-magic-id` element movement with FLIP overlay clones
|
|
28
29
|
|
|
29
30
|
## Slide overrides
|
|
30
31
|
|
|
@@ -42,6 +43,38 @@ transitionEasing: cubic-bezier(0.22, 1, 0.36, 1)
|
|
|
42
43
|
|
|
43
44
|
Slides without transition frontmatter use the deck defaults.
|
|
44
45
|
|
|
46
|
+
## Magic transition
|
|
47
|
+
|
|
48
|
+
`transition: magic` moves only elements you explicitly tag with matching `data-magic-id` values. Honeydeck keeps the outgoing and incoming slides mounted, measures tagged elements at runtime, hides the original tagged elements, animates fixed-position overlay clones, then restores the real slide DOM after the transition.
|
|
49
|
+
|
|
50
|
+
```mdx
|
|
51
|
+
# Before
|
|
52
|
+
|
|
53
|
+
<span data-magic-id="world">World</span>
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
transition: magic
|
|
57
|
+
transitionDuration: 600
|
|
58
|
+
transitionEasing: ease-in-out
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# After
|
|
62
|
+
|
|
63
|
+
Hello <span data-magic-id="world">World</span>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Matching rules:
|
|
67
|
+
|
|
68
|
+
- Same `data-magic-id` on both slides → the element moves.
|
|
69
|
+
- ID only on the previous slide → the element fades out.
|
|
70
|
+
- ID only on the incoming slide → the element fades in.
|
|
71
|
+
- Untagged content crossfades with the slide layers.
|
|
72
|
+
- Different IDs never match, even when the text is identical.
|
|
73
|
+
|
|
74
|
+
Magic is forward-only. When navigating backward into a slide that uses `transition: magic`, Honeydeck falls back to the layer crossfade instead of guessing a reverse morph.
|
|
75
|
+
|
|
76
|
+
Honeydeck does not perform automatic DOM diffing or text/tag heuristics. Layout chrome such as the navigation bar and global slide numbers is outside slide content and switches immediately unless you explicitly tag slide content yourself.
|
|
77
|
+
|
|
45
78
|
## Custom transitions
|
|
46
79
|
|
|
47
80
|
Any transition name that is not built in becomes a CSS hook. For example:
|
package/package.json
CHANGED
package/src/runtime/Deck.tsx
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
EffectiveColorModeProvider,
|
|
48
48
|
} from "./EffectiveColorModeContext.tsx";
|
|
49
49
|
import { rememberSlideRoute } from "./lastSlideRoute.ts";
|
|
50
|
+
import { startMagicTransition } from "./magicTransition.ts";
|
|
50
51
|
import {
|
|
51
52
|
closeOverview,
|
|
52
53
|
toggleOverview as toggleOverviewRoute,
|
|
@@ -85,7 +86,9 @@ function calcScaleFromElement(el: HTMLElement | null): number | null {
|
|
|
85
86
|
|
|
86
87
|
type SlideTransitionState = {
|
|
87
88
|
from: number;
|
|
89
|
+
fromStep: number;
|
|
88
90
|
to: number;
|
|
91
|
+
toStep: number;
|
|
89
92
|
name: string;
|
|
90
93
|
className: string;
|
|
91
94
|
duration: number;
|
|
@@ -134,7 +137,13 @@ function getTransitionOptions(
|
|
|
134
137
|
slideIndex: number,
|
|
135
138
|
): Omit<
|
|
136
139
|
SlideTransitionState,
|
|
137
|
-
|
|
140
|
+
| "from"
|
|
141
|
+
| "fromStep"
|
|
142
|
+
| "to"
|
|
143
|
+
| "toStep"
|
|
144
|
+
| "direction"
|
|
145
|
+
| "enterFromOpacity"
|
|
146
|
+
| "exitFromOpacity"
|
|
138
147
|
> {
|
|
139
148
|
const frontmatter = slideData[slideIndex]?.frontmatter ?? {};
|
|
140
149
|
const name = normalizeTransitionName(
|
|
@@ -338,36 +347,51 @@ export function Deck() {
|
|
|
338
347
|
1,
|
|
339
348
|
Math.min(route.slide, slideData.length || 1),
|
|
340
349
|
);
|
|
341
|
-
const
|
|
350
|
+
const currentStep = Math.max(0, route.step);
|
|
351
|
+
const previousRouteRef = useRef<{ slide: number; step: number } | null>(null);
|
|
342
352
|
const slideLayerRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
|
343
353
|
const [slideTransition, setSlideTransition] =
|
|
344
354
|
useState<SlideTransitionState | null>(null);
|
|
345
355
|
const slideTransitionRef = useRef<SlideTransitionState | null>(null);
|
|
346
356
|
slideTransitionRef.current = slideTransition;
|
|
357
|
+
const magicTransitionScale = scale * slideZoom;
|
|
347
358
|
|
|
348
359
|
useLayoutEffect(() => {
|
|
349
360
|
if (route.view !== "slide") {
|
|
350
|
-
|
|
361
|
+
previousRouteRef.current = { slide: currentSlide, step: currentStep };
|
|
351
362
|
setSlideTransition(null);
|
|
352
363
|
return;
|
|
353
364
|
}
|
|
354
365
|
|
|
355
366
|
if (reducedMotion) {
|
|
356
|
-
|
|
367
|
+
previousRouteRef.current = { slide: currentSlide, step: currentStep };
|
|
357
368
|
setSlideTransition(null);
|
|
358
369
|
return;
|
|
359
370
|
}
|
|
360
371
|
|
|
361
|
-
const
|
|
362
|
-
if (
|
|
363
|
-
|
|
372
|
+
const previousRoute = previousRouteRef.current;
|
|
373
|
+
if (previousRoute === null) {
|
|
374
|
+
previousRouteRef.current = { slide: currentSlide, step: currentStep };
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (previousRoute.slide === currentSlide) {
|
|
378
|
+
previousRouteRef.current = { slide: currentSlide, step: currentStep };
|
|
379
|
+
setSlideTransition((active) =>
|
|
380
|
+
active?.to === currentSlide && active.toStep !== currentStep
|
|
381
|
+
? null
|
|
382
|
+
: active,
|
|
383
|
+
);
|
|
364
384
|
return;
|
|
365
385
|
}
|
|
366
|
-
if (previousSlide === currentSlide) return;
|
|
367
386
|
|
|
387
|
+
const previousSlide = previousRoute.slide;
|
|
368
388
|
const direction: 1 | -1 = currentSlide > previousSlide ? 1 : -1;
|
|
369
|
-
const
|
|
370
|
-
|
|
389
|
+
const rawOptions = getTransitionOptions(currentSlide - 1);
|
|
390
|
+
const options =
|
|
391
|
+
rawOptions.name === "magic" && direction === -1
|
|
392
|
+
? { ...rawOptions, name: "fade", className: "fade" }
|
|
393
|
+
: rawOptions;
|
|
394
|
+
previousRouteRef.current = { slide: currentSlide, step: currentStep };
|
|
371
395
|
|
|
372
396
|
if (options.name === "none" || options.duration === 0) {
|
|
373
397
|
setSlideTransition(null);
|
|
@@ -379,7 +403,9 @@ export function Deck() {
|
|
|
379
403
|
const nextTransition = {
|
|
380
404
|
...options,
|
|
381
405
|
from: previousSlide,
|
|
406
|
+
fromStep: previousRoute.step,
|
|
382
407
|
to: currentSlide,
|
|
408
|
+
toStep: currentStep,
|
|
383
409
|
direction,
|
|
384
410
|
enterFromOpacity: isInterruptingFade
|
|
385
411
|
? (readLayerOpacity(slideLayerRefs.current[currentSlide]) ?? 0)
|
|
@@ -399,7 +425,19 @@ export function Deck() {
|
|
|
399
425
|
}, options.duration);
|
|
400
426
|
|
|
401
427
|
return () => window.clearTimeout(timeout);
|
|
402
|
-
}, [currentSlide, reducedMotion, route.view]);
|
|
428
|
+
}, [currentSlide, currentStep, reducedMotion, route.view]);
|
|
429
|
+
|
|
430
|
+
useLayoutEffect(() => {
|
|
431
|
+
if (!slideTransition || slideTransition.name !== "magic") return;
|
|
432
|
+
return startMagicTransition({
|
|
433
|
+
fromLayer: slideLayerRefs.current[slideTransition.from],
|
|
434
|
+
toLayer: slideLayerRefs.current[slideTransition.to],
|
|
435
|
+
duration: slideTransition.duration,
|
|
436
|
+
easing: slideTransition.easing,
|
|
437
|
+
scale: magicTransitionScale,
|
|
438
|
+
direction: slideTransition.direction,
|
|
439
|
+
});
|
|
440
|
+
}, [magicTransitionScale, slideTransition]);
|
|
403
441
|
|
|
404
442
|
// ── Reference mode: delegate to DocsView ─────────────────────────────
|
|
405
443
|
if (route.view === "kit") {
|
|
@@ -431,7 +469,6 @@ export function Deck() {
|
|
|
431
469
|
);
|
|
432
470
|
}
|
|
433
471
|
|
|
434
|
-
const currentStep = Math.max(0, route.step);
|
|
435
472
|
const controlRoute =
|
|
436
473
|
route.view === "slide" || route.view === "overview"
|
|
437
474
|
? { ...route, slide: currentSlide, step: currentStep }
|
|
@@ -486,6 +523,14 @@ export function Deck() {
|
|
|
486
523
|
? "exit"
|
|
487
524
|
: null;
|
|
488
525
|
const isVisible = isCurrent || transitionRole !== null;
|
|
526
|
+
const slideStepIndex =
|
|
527
|
+
transitionRole === "exit" && activeTransition
|
|
528
|
+
? activeTransition.fromStep
|
|
529
|
+
: transitionRole === "enter" && activeTransition
|
|
530
|
+
? activeTransition.toStep
|
|
531
|
+
: isCurrent
|
|
532
|
+
? currentStep
|
|
533
|
+
: 0;
|
|
489
534
|
const transitionLayerClass =
|
|
490
535
|
transitionRole && activeTransition
|
|
491
536
|
? `honeydeck-transition-${activeTransition.className} honeydeck-transition-${transitionRole}`
|
|
@@ -546,7 +591,7 @@ export function Deck() {
|
|
|
546
591
|
}}
|
|
547
592
|
>
|
|
548
593
|
<TimelineProvider
|
|
549
|
-
stepIndex={
|
|
594
|
+
stepIndex={slideStepIndex}
|
|
550
595
|
stepCount={stepCount}
|
|
551
596
|
>
|
|
552
597
|
<SlideScaleProvider scale={activeSlideScale}>
|
package/src/runtime/SPEC.md
CHANGED
|
@@ -126,7 +126,7 @@ Reference page routes intentionally do not encode slide or step. During one brow
|
|
|
126
126
|
| `↓` / `s` | Next slide; in overview, timeline navigation is disabled: arrow keys move overview selection and WASD are no-ops |
|
|
127
127
|
| `↑` / `w` | Previous slide; in overview, timeline navigation is disabled: arrow keys move overview selection and WASD are no-ops |
|
|
128
128
|
| `o` | Toggle overview mode |
|
|
129
|
-
| `p` | Open presenter mode (same tab) |
|
|
129
|
+
| `p` | Open presenter mode (same tab); presenter mode is unsupported below Tailwind's `md` breakpoint and shows a mobile hint with a button back to the same audience slide/step |
|
|
130
130
|
| `f` | Toggle fullscreen |
|
|
131
131
|
| `Escape` | Exit overview; in reference pages, return to slides; browser-native Escape handles fullscreen exit |
|
|
132
132
|
|
|
@@ -145,7 +145,7 @@ Shown in normal slide view only (not presenter/reference views).
|
|
|
145
145
|
- Overview mode button
|
|
146
146
|
- Layouts reference button (opens `/#/layouts` while preserving the current slide/step as the return target)
|
|
147
147
|
- Docs website button (opens `https://honeydeck.dev` in a new tab)
|
|
148
|
-
- Presenter mode button
|
|
148
|
+
- Presenter mode button on `md` and wider screens
|
|
149
149
|
- Fullscreen button
|
|
150
150
|
- Mobile text selection toggle (off by default, enables selecting slide content when needed)
|
|
151
151
|
- Color mode switch (system → light → dark → system)
|
|
@@ -231,10 +231,12 @@ transitionDuration: 200
|
|
|
231
231
|
transitionEasing: ease
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
-
Built-in transition names are `fade`, `none`,
|
|
234
|
+
Built-in transition names are `fade`, `none`, `slide-left`, and `magic`. Any other string is exposed as a custom CSS hook on the participating slide layers. Legacy `transition: true` maps to `fade`, and `transition: false` maps to `none`.
|
|
235
235
|
|
|
236
236
|
During slide navigation, Honeydeck keeps the outgoing and incoming slide layers mounted inside a scaled slide-sized clipping viewport, applies `honeydeck-slide-layer`, `honeydeck-transition-{name}`, and either `honeydeck-transition-enter` or `honeydeck-transition-exit` only to those two layers, then clears transition state after the configured duration. Transition visuals are clipped to the slide canvas area and must not animate into letterbox or pillarbox bars around the slide. If the next transition is `none` or navigation is interrupted, stale transition state is cleared/replaced so old slides do not remain visible. Outgoing layers are visible during the transition but have pointer events disabled. The built-in `fade` transition uses keyframes and, when a fade is interrupted, starts the next fade from the participating layers' current computed opacity so quick back-and-forth navigation stays close to the old opacity-transition behavior.
|
|
237
237
|
|
|
238
|
+
The built-in `magic` transition is forward-only FLIP behavior for explicitly tagged elements. On forward navigation into a slide with `transition: magic`, Honeydeck measures only elements with `data-magic-id` in the outgoing and incoming slide DOM. Equal IDs move through fixed overlay clones; IDs present only on the outgoing slide fade out through clones; IDs present only on the incoming slide fade in through clones. Untagged slide content still crossfades as part of the slide layers. Honeydeck does not diff arbitrary DOM, text, or tag names, and equal text with different IDs must not match. During magic transitions, Honeydeck hides original tagged elements on both participating slides, copies computed styles recursively onto overlay clones, removes IDs from clones to avoid duplicate document IDs, and accounts for current slide scale when sizing and transforming clones. The magic overlay restores originals and removes itself when animations finish, when navigation interrupts the transition, or after a short timeout fallback. If clone or Web Animations API setup fails, Honeydeck leaves tagged originals visible and uses the layer crossfade. Backward navigation with `transition: magic` falls back to the layer crossfade without overlay matching.
|
|
239
|
+
|
|
238
240
|
Participating slide layers receive CSS variables: `--honeydeck-transition-duration`, `--honeydeck-transition-easing`, and `--honeydeck-transition-direction` (`1` forward, `-1` backward). Built-in `slide-left` uses the direction variable so backward navigation reverses direction. Custom transition CSS can use the same variable for opt-in reverse awareness. Reduced-motion preferences disable slide transition animations.
|
|
239
241
|
|
|
240
242
|
### Aspect Ratio
|
|
@@ -183,12 +183,14 @@ export function NavBar({
|
|
|
183
183
|
|
|
184
184
|
{/* Presenter mode */}
|
|
185
185
|
{route.view !== "presenter" && (
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
<div className="hidden md:block">
|
|
187
|
+
<NavBarButton
|
|
188
|
+
onClick={() => openPresenter(route)}
|
|
189
|
+
label="Presenter mode (p)"
|
|
190
|
+
>
|
|
191
|
+
<PresentationIcon aria-hidden="true" size={16} />
|
|
192
|
+
</NavBarButton>
|
|
193
|
+
</div>
|
|
192
194
|
)}
|
|
193
195
|
</div>
|
|
194
196
|
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
const MAGIC_SELECTOR = "[data-magic-id]";
|
|
2
|
+
|
|
3
|
+
type MagicElementSnapshot = {
|
|
4
|
+
element: HTMLElement;
|
|
5
|
+
rect: DOMRect;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type HiddenOriginal = {
|
|
9
|
+
element: HTMLElement;
|
|
10
|
+
opacity: string;
|
|
11
|
+
transition: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type MagicClonePlan = {
|
|
15
|
+
wrapper: HTMLDivElement;
|
|
16
|
+
fromRect: DOMRect;
|
|
17
|
+
toRect: DOMRect;
|
|
18
|
+
fromOpacity: number;
|
|
19
|
+
toOpacity: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MagicTransitionOptions = {
|
|
23
|
+
fromLayer: HTMLElement | null | undefined;
|
|
24
|
+
toLayer: HTMLElement | null | undefined;
|
|
25
|
+
duration: number;
|
|
26
|
+
easing: string;
|
|
27
|
+
scale: number;
|
|
28
|
+
direction: 1 | -1;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function startMagicTransition({
|
|
32
|
+
fromLayer,
|
|
33
|
+
toLayer,
|
|
34
|
+
duration,
|
|
35
|
+
easing,
|
|
36
|
+
scale,
|
|
37
|
+
direction,
|
|
38
|
+
}: MagicTransitionOptions): () => void {
|
|
39
|
+
if (direction !== 1 || !fromLayer || !toLayer || scale <= 0) {
|
|
40
|
+
return () => {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fromElements = collectMagicElements(fromLayer);
|
|
44
|
+
const toElements = collectMagicElements(toLayer);
|
|
45
|
+
const ids = new Set([...fromElements.keys(), ...toElements.keys()]);
|
|
46
|
+
if (ids.size === 0) return () => {};
|
|
47
|
+
|
|
48
|
+
const overlay = document.createElement("div");
|
|
49
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
50
|
+
overlay.style.position = "fixed";
|
|
51
|
+
overlay.style.inset = "0";
|
|
52
|
+
overlay.style.pointerEvents = "none";
|
|
53
|
+
overlay.style.zIndex = "90";
|
|
54
|
+
overlay.style.overflow = "visible";
|
|
55
|
+
overlay.style.contain = "layout style paint";
|
|
56
|
+
|
|
57
|
+
let plans: MagicClonePlan[];
|
|
58
|
+
try {
|
|
59
|
+
plans = createMagicClonePlans(ids, fromElements, toElements, scale);
|
|
60
|
+
} catch {
|
|
61
|
+
overlay.remove();
|
|
62
|
+
return () => {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (plans.length === 0) return () => {};
|
|
66
|
+
if (typeof plans[0]?.wrapper.animate !== "function") return () => {};
|
|
67
|
+
|
|
68
|
+
const hiddenOriginals = hideOriginals(fromElements, toElements);
|
|
69
|
+
const animations: Animation[] = [];
|
|
70
|
+
let cleaned = false;
|
|
71
|
+
let timeout: number | null = null;
|
|
72
|
+
|
|
73
|
+
function cleanup() {
|
|
74
|
+
if (cleaned) return;
|
|
75
|
+
cleaned = true;
|
|
76
|
+
if (timeout !== null) {
|
|
77
|
+
window.clearTimeout(timeout);
|
|
78
|
+
timeout = null;
|
|
79
|
+
}
|
|
80
|
+
for (const animation of animations) {
|
|
81
|
+
if (animation.playState !== "finished") animation.cancel();
|
|
82
|
+
}
|
|
83
|
+
restoreOriginals(hiddenOriginals);
|
|
84
|
+
overlay.remove();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
document.body.appendChild(overlay);
|
|
89
|
+
|
|
90
|
+
for (const plan of plans) {
|
|
91
|
+
overlay.appendChild(plan.wrapper);
|
|
92
|
+
const offset = alignCloneToRect(plan.wrapper, plan.fromRect, scale);
|
|
93
|
+
applyCloneFrame(
|
|
94
|
+
plan.wrapper,
|
|
95
|
+
plan.fromRect,
|
|
96
|
+
plan.fromOpacity,
|
|
97
|
+
scale,
|
|
98
|
+
offset,
|
|
99
|
+
);
|
|
100
|
+
const animation = animateClone({
|
|
101
|
+
...plan,
|
|
102
|
+
duration,
|
|
103
|
+
easing,
|
|
104
|
+
scale,
|
|
105
|
+
offsetX: offset.x,
|
|
106
|
+
offsetY: offset.y,
|
|
107
|
+
});
|
|
108
|
+
if (!animation) throw new Error("Magic transition animation failed");
|
|
109
|
+
animations.push(animation);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
cleanup();
|
|
113
|
+
return () => {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
void Promise.allSettled(
|
|
117
|
+
animations.map((animation) => animation.finished),
|
|
118
|
+
).then(cleanup);
|
|
119
|
+
timeout = window.setTimeout(cleanup, duration + 50);
|
|
120
|
+
|
|
121
|
+
return cleanup;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function collectMagicElements(
|
|
125
|
+
root: HTMLElement,
|
|
126
|
+
): Map<string, MagicElementSnapshot> {
|
|
127
|
+
const elements = new Map<string, MagicElementSnapshot>();
|
|
128
|
+
for (const element of root.querySelectorAll<HTMLElement>(MAGIC_SELECTOR)) {
|
|
129
|
+
const id = element.dataset.magicId?.trim();
|
|
130
|
+
if (!id || elements.has(id)) continue;
|
|
131
|
+
|
|
132
|
+
const rect = element.getBoundingClientRect();
|
|
133
|
+
if (rect.width <= 0 && rect.height <= 0) continue;
|
|
134
|
+
elements.set(id, { element, rect });
|
|
135
|
+
}
|
|
136
|
+
return elements;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createMagicClonePlans(
|
|
140
|
+
ids: Set<string>,
|
|
141
|
+
fromElements: Map<string, MagicElementSnapshot>,
|
|
142
|
+
toElements: Map<string, MagicElementSnapshot>,
|
|
143
|
+
scale: number,
|
|
144
|
+
): MagicClonePlan[] {
|
|
145
|
+
const plans: MagicClonePlan[] = [];
|
|
146
|
+
for (const id of ids) {
|
|
147
|
+
const from = fromElements.get(id);
|
|
148
|
+
const to = toElements.get(id);
|
|
149
|
+
if (from && to) {
|
|
150
|
+
plans.push({
|
|
151
|
+
wrapper: createMagicClone(to.element, to.rect, scale),
|
|
152
|
+
fromRect: from.rect,
|
|
153
|
+
toRect: to.rect,
|
|
154
|
+
fromOpacity: 1,
|
|
155
|
+
toOpacity: 1,
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (from) {
|
|
161
|
+
plans.push({
|
|
162
|
+
wrapper: createMagicClone(from.element, from.rect, scale),
|
|
163
|
+
fromRect: from.rect,
|
|
164
|
+
toRect: from.rect,
|
|
165
|
+
fromOpacity: 1,
|
|
166
|
+
toOpacity: 0,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (to) {
|
|
172
|
+
plans.push({
|
|
173
|
+
wrapper: createMagicClone(to.element, to.rect, scale),
|
|
174
|
+
fromRect: to.rect,
|
|
175
|
+
toRect: to.rect,
|
|
176
|
+
fromOpacity: 0,
|
|
177
|
+
toOpacity: 1,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return plans;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hideOriginals(
|
|
185
|
+
fromElements: Map<string, MagicElementSnapshot>,
|
|
186
|
+
toElements: Map<string, MagicElementSnapshot>,
|
|
187
|
+
): HiddenOriginal[] {
|
|
188
|
+
const originals = new Set<HTMLElement>();
|
|
189
|
+
for (const { element } of fromElements.values()) originals.add(element);
|
|
190
|
+
for (const { element } of toElements.values()) originals.add(element);
|
|
191
|
+
|
|
192
|
+
return Array.from(originals, (element) => {
|
|
193
|
+
const hidden = {
|
|
194
|
+
element,
|
|
195
|
+
opacity: element.style.opacity,
|
|
196
|
+
transition: element.style.transition,
|
|
197
|
+
};
|
|
198
|
+
element.style.transition = "none";
|
|
199
|
+
element.style.opacity = "0";
|
|
200
|
+
return hidden;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function restoreOriginals(originals: HiddenOriginal[]) {
|
|
205
|
+
for (const { element, opacity, transition } of originals) {
|
|
206
|
+
element.style.opacity = opacity;
|
|
207
|
+
element.style.transition = transition;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function createMagicClone(
|
|
212
|
+
source: HTMLElement,
|
|
213
|
+
rect: DOMRect,
|
|
214
|
+
scale: number,
|
|
215
|
+
): HTMLDivElement {
|
|
216
|
+
const wrapper = document.createElement("div");
|
|
217
|
+
wrapper.style.position = "fixed";
|
|
218
|
+
wrapper.style.left = "0px";
|
|
219
|
+
wrapper.style.top = "0px";
|
|
220
|
+
wrapper.style.margin = "0";
|
|
221
|
+
wrapper.style.width = `${rect.width / scale}px`;
|
|
222
|
+
wrapper.style.height = `${rect.height / scale}px`;
|
|
223
|
+
wrapper.style.pointerEvents = "none";
|
|
224
|
+
wrapper.style.transformOrigin = "top left";
|
|
225
|
+
wrapper.style.transform = transformForRect(rect, scale, 0, 0);
|
|
226
|
+
wrapper.style.opacity = "1";
|
|
227
|
+
wrapper.style.overflow = "visible";
|
|
228
|
+
wrapper.style.lineHeight = "0";
|
|
229
|
+
|
|
230
|
+
const clone = source.cloneNode(true) as HTMLElement;
|
|
231
|
+
const sourceDisplay = window.getComputedStyle(source).display;
|
|
232
|
+
copyComputedStyles(source, clone);
|
|
233
|
+
clone.removeAttribute("id");
|
|
234
|
+
for (const descendant of clone.querySelectorAll("[id]")) {
|
|
235
|
+
descendant.removeAttribute("id");
|
|
236
|
+
}
|
|
237
|
+
clone.style.boxSizing = "border-box";
|
|
238
|
+
clone.style.transform = "none";
|
|
239
|
+
if (sourceDisplay === "inline") {
|
|
240
|
+
clone.style.display = "inline";
|
|
241
|
+
clone.style.width = "auto";
|
|
242
|
+
clone.style.height = "auto";
|
|
243
|
+
} else {
|
|
244
|
+
clone.style.width = "100%";
|
|
245
|
+
clone.style.height = "100%";
|
|
246
|
+
}
|
|
247
|
+
clone.style.margin = "0";
|
|
248
|
+
clone.style.pointerEvents = "none";
|
|
249
|
+
clone.style.transformOrigin = "top left";
|
|
250
|
+
clone.style.verticalAlign = "top";
|
|
251
|
+
wrapper.appendChild(clone);
|
|
252
|
+
return wrapper;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function copyComputedStyles(source: Element, clone: Element) {
|
|
256
|
+
if (!(clone instanceof HTMLElement)) return;
|
|
257
|
+
|
|
258
|
+
const computed = window.getComputedStyle(source);
|
|
259
|
+
for (let index = 0; index < computed.length; index += 1) {
|
|
260
|
+
const property = computed.item(index);
|
|
261
|
+
clone.style.setProperty(
|
|
262
|
+
property,
|
|
263
|
+
computed.getPropertyValue(property),
|
|
264
|
+
computed.getPropertyPriority(property),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const sourceChildren = Array.from(source.children);
|
|
269
|
+
const cloneChildren = Array.from(clone.children);
|
|
270
|
+
for (let index = 0; index < sourceChildren.length; index += 1) {
|
|
271
|
+
const sourceChild = sourceChildren[index];
|
|
272
|
+
const cloneChild = cloneChildren[index];
|
|
273
|
+
if (sourceChild && cloneChild) copyComputedStyles(sourceChild, cloneChild);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function alignCloneToRect(
|
|
278
|
+
wrapper: HTMLDivElement,
|
|
279
|
+
rect: DOMRect,
|
|
280
|
+
scale: number,
|
|
281
|
+
): { x: number; y: number } {
|
|
282
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
283
|
+
const cloneRect =
|
|
284
|
+
wrapper.firstElementChild?.getBoundingClientRect() ?? wrapperRect;
|
|
285
|
+
const x = cloneRect.left - wrapperRect.left;
|
|
286
|
+
const y = cloneRect.top - wrapperRect.top;
|
|
287
|
+
wrapper.style.left = "0px";
|
|
288
|
+
wrapper.style.top = "0px";
|
|
289
|
+
wrapper.style.transform = transformForRect(rect, scale, x, y);
|
|
290
|
+
return { x, y };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function applyCloneFrame(
|
|
294
|
+
wrapper: HTMLDivElement,
|
|
295
|
+
rect: DOMRect,
|
|
296
|
+
opacity: number,
|
|
297
|
+
scale: number,
|
|
298
|
+
offset: { x: number; y: number },
|
|
299
|
+
) {
|
|
300
|
+
wrapper.style.width = `${rect.width / scale}px`;
|
|
301
|
+
wrapper.style.height = `${rect.height / scale}px`;
|
|
302
|
+
wrapper.style.opacity = `${opacity}`;
|
|
303
|
+
wrapper.style.transform = transformForRect(rect, scale, offset.x, offset.y);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function animateClone({
|
|
307
|
+
wrapper,
|
|
308
|
+
fromRect,
|
|
309
|
+
toRect,
|
|
310
|
+
fromOpacity,
|
|
311
|
+
toOpacity,
|
|
312
|
+
duration,
|
|
313
|
+
easing,
|
|
314
|
+
scale,
|
|
315
|
+
offsetX,
|
|
316
|
+
offsetY,
|
|
317
|
+
}: MagicClonePlan & {
|
|
318
|
+
duration: number;
|
|
319
|
+
easing: string;
|
|
320
|
+
scale: number;
|
|
321
|
+
offsetX: number;
|
|
322
|
+
offsetY: number;
|
|
323
|
+
}): Animation | null {
|
|
324
|
+
const keyframes: Keyframe[] = [
|
|
325
|
+
{
|
|
326
|
+
width: `${fromRect.width / scale}px`,
|
|
327
|
+
height: `${fromRect.height / scale}px`,
|
|
328
|
+
opacity: fromOpacity,
|
|
329
|
+
transform: transformForRect(fromRect, scale, offsetX, offsetY),
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
width: `${toRect.width / scale}px`,
|
|
333
|
+
height: `${toRect.height / scale}px`,
|
|
334
|
+
opacity: toOpacity,
|
|
335
|
+
transform: transformForRect(toRect, scale, offsetX, offsetY),
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
if (typeof wrapper.animate !== "function") return null;
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
return wrapper.animate(keyframes, {
|
|
343
|
+
duration,
|
|
344
|
+
easing,
|
|
345
|
+
fill: "both",
|
|
346
|
+
});
|
|
347
|
+
} catch {
|
|
348
|
+
try {
|
|
349
|
+
return wrapper.animate(keyframes, {
|
|
350
|
+
duration,
|
|
351
|
+
easing: "ease",
|
|
352
|
+
fill: "both",
|
|
353
|
+
});
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function transformForRect(
|
|
361
|
+
rect: DOMRect,
|
|
362
|
+
scale: number,
|
|
363
|
+
offsetX: number,
|
|
364
|
+
offsetY: number,
|
|
365
|
+
): string {
|
|
366
|
+
return `translate(${rect.left - offsetX}px, ${rect.top - offsetY}px) scale(${scale})`;
|
|
367
|
+
}
|
|
@@ -30,10 +30,6 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import {
|
|
33
|
-
ChevronDownIcon,
|
|
34
|
-
ChevronLeftIcon,
|
|
35
|
-
ChevronRightIcon,
|
|
36
|
-
ChevronUpIcon,
|
|
37
33
|
ExternalLinkIcon,
|
|
38
34
|
MonitorOffIcon,
|
|
39
35
|
PauseIcon,
|
|
@@ -57,11 +53,7 @@ import { NotesContext } from "../components/Notes.tsx";
|
|
|
57
53
|
import {
|
|
58
54
|
getSlideRouteFromRoute,
|
|
59
55
|
navigateTo,
|
|
60
|
-
nextSlide,
|
|
61
|
-
nextStep,
|
|
62
56
|
openUrlInNewTab,
|
|
63
|
-
previousSlide,
|
|
64
|
-
previousStep,
|
|
65
57
|
} from "../navigation.ts";
|
|
66
58
|
import {
|
|
67
59
|
getPresentationAudienceUrl,
|
|
@@ -72,7 +64,6 @@ import { SlideCanvas } from "../SlideCanvas.tsx";
|
|
|
72
64
|
import { BASE_HEIGHT, BASE_WIDTH, slideData } from "../slideData.ts";
|
|
73
65
|
import { useSync } from "../sync.ts";
|
|
74
66
|
import { useKeyboardNav } from "../useKeyboardNav.ts";
|
|
75
|
-
import { useSwipeNav } from "../useSwipeNav.ts";
|
|
76
67
|
import { PresenterCastButton } from "./PresenterCastButton.tsx";
|
|
77
68
|
import { PresenterNotesPanel } from "./PresenterNotesPanel.tsx";
|
|
78
69
|
import { getPresenterNextPreview } from "./presenterPreview.ts";
|
|
@@ -154,23 +145,6 @@ function SlidePreview({
|
|
|
154
145
|
);
|
|
155
146
|
}
|
|
156
147
|
|
|
157
|
-
function usePresenterMobile(): boolean {
|
|
158
|
-
const [isMobile, setIsMobile] = useState(() => {
|
|
159
|
-
if (typeof window === "undefined") return false;
|
|
160
|
-
return window.matchMedia("(max-width: 767px)").matches;
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
useEffect(() => {
|
|
164
|
-
const query = window.matchMedia("(max-width: 767px)");
|
|
165
|
-
const update = () => setIsMobile(query.matches);
|
|
166
|
-
update();
|
|
167
|
-
query.addEventListener("change", update);
|
|
168
|
-
return () => query.removeEventListener("change", update);
|
|
169
|
-
}, []);
|
|
170
|
-
|
|
171
|
-
return isMobile;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
148
|
// ---------------------------------------------------------------------------
|
|
175
149
|
// Component
|
|
176
150
|
// ---------------------------------------------------------------------------
|
|
@@ -197,7 +171,6 @@ export function PresenterView({
|
|
|
197
171
|
const [notes, setNotes] = useState<ReactNode>(null);
|
|
198
172
|
const [isBlankScreen, setIsBlankScreen] = useState(false);
|
|
199
173
|
const notesContextValue = useMemo(() => ({ setNotes }), []);
|
|
200
|
-
const isMobile = usePresenterMobile();
|
|
201
174
|
|
|
202
175
|
const currentIndex = slide - 1; // 0-based
|
|
203
176
|
const currentStepCount = slideData[currentIndex]?.stepCount ?? 0;
|
|
@@ -243,7 +216,6 @@ export function PresenterView({
|
|
|
243
216
|
getStepCount,
|
|
244
217
|
onToggleOverview: () => {},
|
|
245
218
|
});
|
|
246
|
-
useSwipeNav({ enabled: isMobile });
|
|
247
219
|
|
|
248
220
|
// ── Presenter exit + blank screen shortcuts ─────────────────────────────
|
|
249
221
|
const closePresenter = useCallback(() => {
|
|
@@ -305,60 +277,50 @@ export function PresenterView({
|
|
|
305
277
|
if (url) openUrlInNewTab(url);
|
|
306
278
|
}
|
|
307
279
|
|
|
308
|
-
// ── Navigate (presenter stays on presenter route) ───────────────────────
|
|
309
|
-
function goNext() {
|
|
310
|
-
nextStep(
|
|
311
|
-
{ view: "presenter", slide, step },
|
|
312
|
-
{ slideCount: totalSlides, getStepCount },
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function goPrev() {
|
|
317
|
-
previousStep(
|
|
318
|
-
{ view: "presenter", slide, step },
|
|
319
|
-
{ slideCount: totalSlides, getStepCount },
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function goNextSlide() {
|
|
324
|
-
nextSlide({ view: "presenter", slide, step }, { slideCount: totalSlides });
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function goPrevSlide() {
|
|
328
|
-
previousSlide({ view: "presenter", slide, step });
|
|
329
|
-
}
|
|
330
|
-
|
|
331
280
|
// ---------------------------------------------------------------------------
|
|
332
281
|
// Render
|
|
333
282
|
// ---------------------------------------------------------------------------
|
|
334
283
|
|
|
335
284
|
return (
|
|
336
|
-
<div className="fixed inset-0 bg-black text-white font-sans
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
285
|
+
<div className="fixed inset-0 bg-black text-white font-sans overflow-hidden select-none">
|
|
286
|
+
<div className="flex h-full flex-col items-center justify-center gap-5 px-6 text-center md:hidden">
|
|
287
|
+
<div className="max-w-sm space-y-2">
|
|
288
|
+
<h1 className="text-2xl font-semibold">
|
|
289
|
+
Presenter mode is not supported on mobile.
|
|
290
|
+
</h1>
|
|
291
|
+
<p className="text-sm text-white/60">
|
|
292
|
+
Open presenter mode on a larger screen, or return to this slide in
|
|
293
|
+
the audience view.
|
|
294
|
+
</p>
|
|
341
295
|
</div>
|
|
342
|
-
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
onClick={closePresenter}
|
|
299
|
+
className="rounded border border-white/20 bg-white/10 px-4 py-2 text-sm font-[inherit] text-white/85"
|
|
300
|
+
>
|
|
301
|
+
Go to slide
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="hidden h-full grid-rows-[1fr_auto] grid-cols-1 md:grid">
|
|
306
|
+
{/* ── Blank screen indicator overlay ────────────────────────────── */}
|
|
307
|
+
{isBlankScreen && (
|
|
308
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 px-4 py-2 rounded-md bg-red-600/90 text-white text-sm font-semibold tracking-wide uppercase">
|
|
309
|
+
Screen blanked (b)
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{/* ── Top section: current slide plus next/notes column ─────────── */}
|
|
314
|
+
<div className="grid grid-cols-[minmax(0,3fr)_minmax(280px,2fr)] gap-4 px-4 pt-4 pb-2 min-h-0 overflow-hidden">
|
|
315
|
+
{/* Current slide owns NotesContext. Next preview must not overwrite notes. */}
|
|
316
|
+
<NotesContext.Provider value={notesContextValue}>
|
|
317
|
+
<SlidePreview
|
|
318
|
+
slideIndex={currentIndex}
|
|
319
|
+
stepIndex={step}
|
|
320
|
+
label="Current"
|
|
321
|
+
/>
|
|
322
|
+
</NotesContext.Provider>
|
|
343
323
|
|
|
344
|
-
{/* ── Top section: current slide plus desktop next/notes column ─── */}
|
|
345
|
-
<div
|
|
346
|
-
className={
|
|
347
|
-
isMobile
|
|
348
|
-
? "grid grid-cols-1 gap-3 px-3 pt-3 pb-2 min-h-0 overflow-hidden"
|
|
349
|
-
: "grid grid-cols-[minmax(0,3fr)_minmax(280px,2fr)] gap-4 px-4 pt-4 pb-2 min-h-0 overflow-hidden"
|
|
350
|
-
}
|
|
351
|
-
>
|
|
352
|
-
{/* Current slide owns NotesContext. Next preview must not overwrite notes. */}
|
|
353
|
-
<NotesContext.Provider value={notesContextValue}>
|
|
354
|
-
<SlidePreview
|
|
355
|
-
slideIndex={currentIndex}
|
|
356
|
-
stepIndex={step}
|
|
357
|
-
label="Current"
|
|
358
|
-
/>
|
|
359
|
-
</NotesContext.Provider>
|
|
360
|
-
|
|
361
|
-
{!isMobile && (
|
|
362
324
|
<div className="grid grid-rows-[minmax(0,1fr)_minmax(8rem,0.8fr)] gap-4 min-h-0 overflow-hidden">
|
|
363
325
|
<SlidePreview
|
|
364
326
|
slideIndex={nextPreview?.slideIndex ?? -1}
|
|
@@ -368,177 +330,123 @@ export function PresenterView({
|
|
|
368
330
|
/>
|
|
369
331
|
<PresenterNotesPanel notes={notes} />
|
|
370
332
|
</div>
|
|
371
|
-
|
|
372
|
-
</div>
|
|
373
|
-
|
|
374
|
-
{/* ── Mobile notes live below the current slide ─────────────────── */}
|
|
375
|
-
{isMobile && (
|
|
376
|
-
<PresenterNotesPanel
|
|
377
|
-
notes={notes}
|
|
378
|
-
className="mx-3 mb-2 min-h-20 max-h-40"
|
|
379
|
-
/>
|
|
380
|
-
)}
|
|
333
|
+
</div>
|
|
381
334
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
<div className="min-w-0 flex flex-wrap items-center gap-3 text-md text-white/60 tabular-nums">
|
|
392
|
-
<span className="truncate">
|
|
393
|
-
Slide {slide}/{totalSlides}
|
|
394
|
-
{currentStepCount > 0 && ` · Step ${step}/${currentStepCount}`}
|
|
395
|
-
</span>
|
|
396
|
-
{timerState === "idle" && (
|
|
397
|
-
<button
|
|
398
|
-
type="button"
|
|
399
|
-
onClick={startTimer}
|
|
400
|
-
title="Start timer"
|
|
401
|
-
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/6 text-white/50 hover:text-white/80 text-sm"
|
|
402
|
-
>
|
|
403
|
-
<PlayIcon aria-hidden="true" size={14} />
|
|
404
|
-
Start timer
|
|
405
|
-
</button>
|
|
406
|
-
)}
|
|
407
|
-
{timerState === "running" && (
|
|
408
|
-
<>
|
|
409
|
-
<button
|
|
410
|
-
type="button"
|
|
411
|
-
onClick={pauseTimer}
|
|
412
|
-
title="Pause timer"
|
|
413
|
-
className="text-lg font-semibold text-white tabular-nums bg-white/10 px-2.5 py-0.5 rounded"
|
|
414
|
-
>
|
|
415
|
-
⏱ {elapsedTime}
|
|
416
|
-
</button>
|
|
417
|
-
<button
|
|
418
|
-
type="button"
|
|
419
|
-
onClick={pauseTimer}
|
|
420
|
-
title="Pause timer"
|
|
421
|
-
aria-label="Pause timer"
|
|
422
|
-
className="text-white/50 hover:text-white/80 inline-flex"
|
|
423
|
-
>
|
|
424
|
-
<PauseIcon aria-hidden="true" size={16} />
|
|
425
|
-
</button>
|
|
426
|
-
</>
|
|
427
|
-
)}
|
|
428
|
-
{timerState === "paused" && (
|
|
429
|
-
<>
|
|
430
|
-
<span className="text-lg font-semibold text-white/60 tabular-nums bg-white/6 px-2.5 py-0.5 rounded">
|
|
431
|
-
⏱ {elapsedTime}
|
|
432
|
-
</span>
|
|
433
|
-
<button
|
|
434
|
-
type="button"
|
|
435
|
-
onClick={continueTimer}
|
|
436
|
-
title="Continue timer"
|
|
437
|
-
aria-label="Continue timer"
|
|
438
|
-
className="text-white/50 hover:text-white/80 inline-flex"
|
|
439
|
-
>
|
|
440
|
-
<PlayIcon aria-hidden="true" size={16} />
|
|
441
|
-
</button>
|
|
442
|
-
<button
|
|
443
|
-
type="button"
|
|
444
|
-
onClick={restartTimer}
|
|
445
|
-
title="Restart timer from zero"
|
|
446
|
-
aria-label="Restart timer from zero"
|
|
447
|
-
className="text-white/30 hover:text-white/60 inline-flex"
|
|
448
|
-
>
|
|
449
|
-
<RotateCcwIcon aria-hidden="true" size={15} />
|
|
450
|
-
</button>
|
|
335
|
+
{/* ── Bottom bar: counter · timer/actions ──────────────────────── */}
|
|
336
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center px-5 py-2.5 border-t border-white/8 bg-black/30 gap-x-4 gap-y-2">
|
|
337
|
+
{/* Counter + timer */}
|
|
338
|
+
<div className="min-w-0 flex flex-wrap items-center gap-3 text-md text-white/60 tabular-nums">
|
|
339
|
+
<span className="truncate">
|
|
340
|
+
Slide {slide}/{totalSlides}
|
|
341
|
+
{currentStepCount > 0 && ` · Step ${step}/${currentStepCount}`}
|
|
342
|
+
</span>
|
|
343
|
+
{timerState === "idle" && (
|
|
451
344
|
<button
|
|
452
345
|
type="button"
|
|
453
|
-
onClick={
|
|
454
|
-
title="
|
|
455
|
-
|
|
456
|
-
className="text-white/30 hover:text-white/60 inline-flex"
|
|
346
|
+
onClick={startTimer}
|
|
347
|
+
title="Start timer"
|
|
348
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/6 text-white/50 hover:text-white/80 text-sm"
|
|
457
349
|
>
|
|
458
|
-
<
|
|
350
|
+
<PlayIcon aria-hidden="true" size={14} />
|
|
351
|
+
Start timer
|
|
459
352
|
</button>
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
353
|
+
)}
|
|
354
|
+
{timerState === "running" && (
|
|
355
|
+
<>
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
onClick={pauseTimer}
|
|
359
|
+
title="Pause timer"
|
|
360
|
+
className="text-lg font-semibold text-white tabular-nums bg-white/10 px-2.5 py-0.5 rounded"
|
|
361
|
+
>
|
|
362
|
+
⏱ {elapsedTime}
|
|
363
|
+
</button>
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={pauseTimer}
|
|
367
|
+
title="Pause timer"
|
|
368
|
+
aria-label="Pause timer"
|
|
369
|
+
className="text-white/50 hover:text-white/80 inline-flex"
|
|
370
|
+
>
|
|
371
|
+
<PauseIcon aria-hidden="true" size={16} />
|
|
372
|
+
</button>
|
|
373
|
+
</>
|
|
374
|
+
)}
|
|
375
|
+
{timerState === "paused" && (
|
|
376
|
+
<>
|
|
377
|
+
<span className="text-lg font-semibold text-white/60 tabular-nums bg-white/6 px-2.5 py-0.5 rounded">
|
|
378
|
+
⏱ {elapsedTime}
|
|
379
|
+
</span>
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
onClick={continueTimer}
|
|
383
|
+
title="Continue timer"
|
|
384
|
+
aria-label="Continue timer"
|
|
385
|
+
className="text-white/50 hover:text-white/80 inline-flex"
|
|
386
|
+
>
|
|
387
|
+
<PlayIcon aria-hidden="true" size={16} />
|
|
388
|
+
</button>
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
onClick={restartTimer}
|
|
392
|
+
title="Restart timer from zero"
|
|
393
|
+
aria-label="Restart timer from zero"
|
|
394
|
+
className="text-white/30 hover:text-white/60 inline-flex"
|
|
395
|
+
>
|
|
396
|
+
<RotateCcwIcon aria-hidden="true" size={15} />
|
|
397
|
+
</button>
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
onClick={closeTimer}
|
|
401
|
+
title="Close timer"
|
|
402
|
+
aria-label="Close timer"
|
|
403
|
+
className="text-white/30 hover:text-white/60 inline-flex"
|
|
404
|
+
>
|
|
405
|
+
<XIcon aria-hidden="true" size={16} />
|
|
406
|
+
</button>
|
|
407
|
+
</>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
463
410
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
title="Previous step (←)"
|
|
470
|
-
className="h-12 w-16"
|
|
411
|
+
{/* Clock + mode/open/cast buttons */}
|
|
412
|
+
<div className="flex flex-wrap gap-3 items-center justify-self-end">
|
|
413
|
+
<span
|
|
414
|
+
className="text-md tabular-nums text-white/60"
|
|
415
|
+
title="Wall clock"
|
|
471
416
|
>
|
|
472
|
-
|
|
473
|
-
</
|
|
474
|
-
<
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
</NavButton>
|
|
417
|
+
{clock}
|
|
418
|
+
</span>
|
|
419
|
+
<ColorModeCycleButton
|
|
420
|
+
colorMode={colorMode}
|
|
421
|
+
onSetColorMode={onSetColorMode}
|
|
422
|
+
iconSize={14}
|
|
423
|
+
className="w-8 h-8 rounded border border-white/20 bg-white/6 text-white/80 inline-flex items-center justify-center"
|
|
424
|
+
/>
|
|
481
425
|
<NavButton
|
|
482
|
-
onClick={
|
|
483
|
-
title="
|
|
484
|
-
className="h-12 w-16"
|
|
426
|
+
onClick={toggleBlankScreen}
|
|
427
|
+
title={isBlankScreen ? "Unblank screen (b)" : "Blank screen (b)"}
|
|
485
428
|
>
|
|
486
|
-
<
|
|
429
|
+
<MonitorOffIcon
|
|
430
|
+
aria-hidden="true"
|
|
431
|
+
size={16}
|
|
432
|
+
className={isBlankScreen ? "text-red-400" : ""}
|
|
433
|
+
/>
|
|
487
434
|
</NavButton>
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
className="
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
onClick={openAudienceView}
|
|
438
|
+
className="px-3 py-1 rounded border border-white/20 bg-white/6 text-white/80 text-sm font-[inherit] inline-flex items-center gap-1.5"
|
|
492
439
|
>
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
isMobile
|
|
502
|
-
? "col-span-2 flex flex-wrap gap-3 items-center justify-self-start"
|
|
503
|
-
: "flex flex-wrap gap-3 items-center justify-self-end"
|
|
504
|
-
}
|
|
505
|
-
>
|
|
506
|
-
<span
|
|
507
|
-
className="text-md tabular-nums text-white/60"
|
|
508
|
-
title="Wall clock"
|
|
509
|
-
>
|
|
510
|
-
{clock}
|
|
511
|
-
</span>
|
|
512
|
-
<ColorModeCycleButton
|
|
513
|
-
colorMode={colorMode}
|
|
514
|
-
onSetColorMode={onSetColorMode}
|
|
515
|
-
iconSize={14}
|
|
516
|
-
className="w-8 h-8 rounded border border-white/20 bg-white/6 text-white/80 inline-flex items-center justify-center"
|
|
517
|
-
/>
|
|
518
|
-
<NavButton
|
|
519
|
-
onClick={toggleBlankScreen}
|
|
520
|
-
title={isBlankScreen ? "Unblank screen (b)" : "Blank screen (b)"}
|
|
521
|
-
>
|
|
522
|
-
<MonitorOffIcon
|
|
523
|
-
aria-hidden="true"
|
|
524
|
-
size={16}
|
|
525
|
-
className={isBlankScreen ? "text-red-400" : ""}
|
|
440
|
+
Open audience view
|
|
441
|
+
<ExternalLinkIcon aria-hidden="true" size={14} />
|
|
442
|
+
</button>
|
|
443
|
+
<PresenterCastButton
|
|
444
|
+
supported={presentationCast.supported}
|
|
445
|
+
isCasting={presentationCast.isCasting}
|
|
446
|
+
onStartCasting={presentationCast.startCasting}
|
|
447
|
+
onStopCasting={presentationCast.stopCasting}
|
|
526
448
|
/>
|
|
527
|
-
</
|
|
528
|
-
<button
|
|
529
|
-
type="button"
|
|
530
|
-
onClick={openAudienceView}
|
|
531
|
-
className="px-3 py-1 rounded border border-white/20 bg-white/6 text-white/80 text-sm font-[inherit] inline-flex items-center gap-1.5"
|
|
532
|
-
>
|
|
533
|
-
Open audience view
|
|
534
|
-
<ExternalLinkIcon aria-hidden="true" size={14} />
|
|
535
|
-
</button>
|
|
536
|
-
<PresenterCastButton
|
|
537
|
-
supported={presentationCast.supported}
|
|
538
|
-
isCasting={presentationCast.isCasting}
|
|
539
|
-
onStartCasting={presentationCast.startCasting}
|
|
540
|
-
onStopCasting={presentationCast.stopCasting}
|
|
541
|
-
/>
|
|
449
|
+
</div>
|
|
542
450
|
</div>
|
|
543
451
|
</div>
|
|
544
452
|
</div>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
### Activation
|
|
8
8
|
|
|
9
9
|
- Keyboard shortcut `p` (opens presenter mode in the current tab)
|
|
10
|
-
- Navigation controls button
|
|
10
|
+
- Navigation controls button on `md` and wider screens
|
|
11
11
|
- Direct URL: `/#/presenter/1/0`
|
|
12
12
|
|
|
13
13
|
### Deactivation
|
|
@@ -45,17 +45,15 @@ Includes:
|
|
|
45
45
|
- Color mode cycle button (system → light → dark → system). Presenter color mode changes also sync to BroadcastChannel audience views and Presentation API cast receivers.
|
|
46
46
|
- Blank screen toggle button and `b` keyboard shortcut. While blanked, the presenter sees a `Screen blanked (b)` indicator and audience/cast views see a black screen.
|
|
47
47
|
- Button to cast the audience view to a secondary display when the Presentation API is supported. Unsupported browsers keep a visibly disabled-looking control in the action row; it does not render extra inline feedback, and its hover title/accessible label explains that Presentation API casting is unavailable. Active casting can be stopped from the same control.
|
|
48
|
-
-
|
|
49
|
-
- Presenter navigation uses the shared Honeydeck navigation command abstraction so button, keyboard, and touch inputs share the same semantics as audience view.
|
|
48
|
+
- Timeline keyboard shortcuts (`→`/`←`/`↓`/`↑`, `d`/`a`/`s`/`w`) update the presenter route and keep the window in presenter mode on supported desktop layouts.
|
|
50
49
|
- Presenter notes are scroll-owned regions: wheel, trackpad, touch scroll, and swipe gestures that start in notes scroll notes and never navigate slides, even at scroll boundaries.
|
|
51
|
-
- On mobile presenter layouts, the Current preview may use tap zones and swipe navigation; speaker notes remain scroll-only. Pinch-to-zoom and pinch-to-overview are not required in presenter mode.
|
|
52
50
|
- Code step-through previews use the same timeline state as audience view, so the Next preview shows the upcoming highlighted code step.
|
|
53
51
|
- In the Next preview, reveal content from later timeline steps is visible at reduced opacity so the speaker can see what is still coming on that slide. Audience view and the Current preview keep future steps hidden.
|
|
54
52
|
- When no next timeline state exists (final step of the final slide), the Next preview shows a placeholder (`No next step`) instead of trying to render a missing slide.
|
|
55
53
|
|
|
56
54
|
### Presenter Responsiveness
|
|
57
55
|
|
|
58
|
-
Presenter mode uses a two-column preview area (`Current` larger, `Next` smaller), a notes panel, and a bottom status/action bar on
|
|
56
|
+
Presenter mode uses a two-column preview area (`Current` larger, `Next` smaller), a notes panel, and a bottom status/action bar on `md` and wider screens. Below Tailwind's `md` breakpoint, presenter mode is not supported: direct presenter URLs show a full-page hint that presenter mode is not supported on mobile and provide a button back to the same slide/step in audience view.
|
|
59
57
|
|
|
60
58
|
### Presentation Timer
|
|
61
59
|
|
package/src/theme/base.css
CHANGED
|
@@ -547,6 +547,20 @@
|
|
|
547
547
|
animation-fill-mode: both;
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
.honeydeck-slide-layer.honeydeck-transition-magic.honeydeck-transition-enter {
|
|
551
|
+
animation-name: honeydeck-transition-fade-enter;
|
|
552
|
+
animation-duration: var(--honeydeck-transition-duration, 200ms);
|
|
553
|
+
animation-timing-function: var(--honeydeck-transition-easing, ease);
|
|
554
|
+
animation-fill-mode: both;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.honeydeck-slide-layer.honeydeck-transition-magic.honeydeck-transition-exit {
|
|
558
|
+
animation-name: honeydeck-transition-fade-exit;
|
|
559
|
+
animation-duration: var(--honeydeck-transition-duration, 200ms);
|
|
560
|
+
animation-timing-function: var(--honeydeck-transition-easing, ease);
|
|
561
|
+
animation-fill-mode: both;
|
|
562
|
+
}
|
|
563
|
+
|
|
550
564
|
@keyframes honeydeck-transition-fade-enter {
|
|
551
565
|
from {
|
|
552
566
|
opacity: var(--honeydeck-transition-enter-from-opacity, 0);
|
package/src/vite-plugin/SPEC.md
CHANGED
|
@@ -108,7 +108,7 @@ All settings use **camelCase**. No separate config file exists. Frontmatter pars
|
|
|
108
108
|
| `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Browser color mode |
|
|
109
109
|
| `pdfColorMode` | `"light" \| "dark"` | unset | Optional explicit PDF color mode; when unset, PDF falls back to pinned deck `colorMode`, then `light` |
|
|
110
110
|
| `pdfSteps` | `"final" \| "all"` | `"final"` | Whether PDF includes all steps or final state |
|
|
111
|
-
| `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, or a custom CSS name); legacy `true` maps to `fade` and `false` maps to `none` |
|
|
111
|
+
| `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, `magic`, or a custom CSS name); legacy `true` maps to `fade` and `false` maps to `none` |
|
|
112
112
|
| `transitionDuration` | `number` | `200` | Default slide transition duration in milliseconds |
|
|
113
113
|
| `transitionEasing` | `string` | `ease` | Default slide transition timing function |
|
|
114
114
|
| `magicCodeDuration` | `number` | `800` | Default Magic Code animation duration in milliseconds |
|
|
@@ -132,6 +132,6 @@ The first frontmatter block in the deck entry file is parsed as deck config. Dec
|
|
|
132
132
|
|
|
133
133
|
Slide-level frontmatter is a frontmatter-only block after a slide separator and applies to the following slide. Imported MDX files are normal MDX modules and cannot set deck-level properties. `magicCodeDuration` is deck-level only; the same key in slide-level frontmatter is treated as a normal layout prop and does not configure Magic Code.
|
|
134
134
|
|
|
135
|
-
Invalid `aspectRatio`, `colorMode`, and `pdfSteps` values fall back to defaults. Invalid `pdfColorMode` is ignored as unset, allowing the pinned `colorMode` fallback. `showSlideNumbers` is enabled only by literal `true`; slide transition values normalize at runtime, with non-empty strings treated as named built-ins or custom CSS hooks. Invalid explicit Magic Code block `duration` values are compile errors; invalid deck-level `magicCodeDuration` falls back to the default Magic Code duration.
|
|
135
|
+
Invalid `aspectRatio`, `colorMode`, and `pdfSteps` values fall back to defaults. Invalid `pdfColorMode` is ignored as unset, allowing the pinned `colorMode` fallback. `showSlideNumbers` is enabled only by literal `true`; slide transition values normalize at runtime, with non-empty strings treated as named built-ins or custom CSS hooks. `transition: magic` uses only runtime `data-magic-id` matching and does not add build-time DOM diffing. Invalid explicit Magic Code block `duration` values are compile errors; invalid deck-level `magicCodeDuration` falls back to the default Magic Code duration.
|
|
136
136
|
|
|
137
137
|
During development, changes to deck-level frontmatter invalidate the virtual config and every compiled virtual slide module, because slide compilation can depend on deck settings such as `magicCodeDuration`. Layout-related virtual modules are invalidated as before so layout map and demo previews stay current.
|