@honeydeck/honeydeck 0.1.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/AGENTS.md +25 -0
- package/DEVELOPMENT.md +522 -0
- package/LICENSE +21 -0
- package/Readme.md +49 -0
- package/SPEC.md +88 -0
- package/docs/components.md +63 -0
- package/docs/configuration.md +91 -0
- package/docs/getting-started.md +116 -0
- package/docs/kit-authoring.md +207 -0
- package/docs/kits.md +387 -0
- package/docs/local-development.md +95 -0
- package/docs/mermaid.md +198 -0
- package/docs/mobile.md +108 -0
- package/docs/navigation.md +93 -0
- package/docs/next-steps.md +377 -0
- package/docs/pdf-export.md +91 -0
- package/docs/presenter-mode.md +104 -0
- package/docs/slides.md +130 -0
- package/docs/slidev-migration.md +42 -0
- package/docs/steps-and-reveals.md +171 -0
- package/package.json +134 -0
- package/skills/SPEC.md +21 -0
- package/skills/honeydeck/SKILL.md +65 -0
- package/skills/presentation-writing/SKILL.md +75 -0
- package/skills/slidev-migration/SKILL.md +153 -0
- package/src/SPEC.md +89 -0
- package/src/assets.d.ts +30 -0
- package/src/cli/SPEC.md +230 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/banner.ts +9 -0
- package/src/cli/bin.js +5 -0
- package/src/cli/build.ts +229 -0
- package/src/cli/deck-path.ts +32 -0
- package/src/cli/dev.ts +263 -0
- package/src/cli/index.ts +126 -0
- package/src/cli/init.ts +369 -0
- package/src/cli/pdf.ts +923 -0
- package/src/cli/skill.ts +75 -0
- package/src/cli/templates/SPEC.md +70 -0
- package/src/cli/templates/deck-mdx.ts +15 -0
- package/src/cli/templates/package-json.ts +36 -0
- package/src/cli/templates/sparkle-button.ts +15 -0
- package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
- package/src/cli/templates/starter/deck.mdx +153 -0
- package/src/cli/templates/starter/styles.css +14 -0
- package/src/cli/templates/styles-css.ts +14 -0
- package/src/defaults.ts +1 -0
- package/src/layouts/ColorModeImage.tsx +55 -0
- package/src/layouts/SPEC.md +393 -0
- package/src/layouts/SlideFrame.tsx +48 -0
- package/src/layouts/bee/Blank.tsx +12 -0
- package/src/layouts/bee/Cover.tsx +70 -0
- package/src/layouts/bee/Default.tsx +42 -0
- package/src/layouts/bee/Image/Image.tsx +151 -0
- package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
- package/src/layouts/bee/Image/placeholder.webp +0 -0
- package/src/layouts/bee/ImageLeft.tsx +27 -0
- package/src/layouts/bee/ImageRight.tsx +27 -0
- package/src/layouts/bee/ImageSide.tsx +107 -0
- package/src/layouts/bee/Section.tsx +40 -0
- package/src/layouts/bee/TwoCol.tsx +108 -0
- package/src/layouts/bee/index.ts +40 -0
- package/src/layouts/clean/Blank.tsx +12 -0
- package/src/layouts/clean/Cover.tsx +58 -0
- package/src/layouts/clean/Default.tsx +33 -0
- package/src/layouts/clean/Image/Image.tsx +103 -0
- package/src/layouts/clean/ImageLeft.tsx +27 -0
- package/src/layouts/clean/ImageRight.tsx +27 -0
- package/src/layouts/clean/ImageSide.tsx +113 -0
- package/src/layouts/clean/Section.tsx +35 -0
- package/src/layouts/clean/TwoCol.tsx +63 -0
- package/src/layouts/clean/index.ts +40 -0
- package/src/layouts/index.ts +60 -0
- package/src/layouts/placeholders.ts +9 -0
- package/src/layouts/utils.ts +13 -0
- package/src/remark/SPEC.md +49 -0
- package/src/remark/h1-extract.ts +124 -0
- package/src/remark/index.ts +4 -0
- package/src/remark/shiki-code-blocks.ts +325 -0
- package/src/remark/step-numbering.ts +412 -0
- package/src/runtime/Deck.tsx +533 -0
- package/src/runtime/SPEC.md +256 -0
- package/src/runtime/SlideCanvas.tsx +95 -0
- package/src/runtime/TimelineContext.tsx +122 -0
- package/src/runtime/app-shell/index.html +31 -0
- package/src/runtime/app-shell/main.tsx +42 -0
- package/src/runtime/aspectRatio.ts +34 -0
- package/src/runtime/colorMode.ts +23 -0
- package/src/runtime/components/BrowserFrame.tsx +233 -0
- package/src/runtime/components/Button.tsx +57 -0
- package/src/runtime/components/CodeBlock.tsx +210 -0
- package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
- package/src/runtime/components/ErrorBoundary.tsx +125 -0
- package/src/runtime/components/Keyboard.tsx +87 -0
- package/src/runtime/components/ListStyle.tsx +203 -0
- package/src/runtime/components/NavBar.tsx +223 -0
- package/src/runtime/components/NavBarButton.tsx +47 -0
- package/src/runtime/components/NavBarDivider.tsx +3 -0
- package/src/runtime/components/Notes.tsx +171 -0
- package/src/runtime/components/Reveal.tsx +82 -0
- package/src/runtime/components/RevealGroup.tsx +193 -0
- package/src/runtime/components/SPEC.md +263 -0
- package/src/runtime/components/SlideNumberBadge.tsx +11 -0
- package/src/runtime/components/TimelineSteps.tsx +115 -0
- package/src/runtime/components/index.ts +55 -0
- package/src/runtime/index.ts +42 -0
- package/src/runtime/inputOwnership.ts +68 -0
- package/src/runtime/keyboardTarget.ts +7 -0
- package/src/runtime/lastSlideRoute.ts +56 -0
- package/src/runtime/navigation.ts +211 -0
- package/src/runtime/router.ts +157 -0
- package/src/runtime/slideData.ts +137 -0
- package/src/runtime/sync.ts +267 -0
- package/src/runtime/types.ts +182 -0
- package/src/runtime/useKeyboardNav.ts +138 -0
- package/src/runtime/useSwipeNav.ts +257 -0
- package/src/runtime/views/DocsView.tsx +74 -0
- package/src/runtime/views/OverviewView.tsx +386 -0
- package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
- package/src/runtime/views/PresenterView.tsx +340 -0
- package/src/runtime/views/SPEC.md +152 -0
- package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
- package/src/runtime/views/docs/DocsHeader.tsx +101 -0
- package/src/runtime/views/docs/Intro.tsx +20 -0
- package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
- package/src/runtime/views/docs/ThemeTab.tsx +110 -0
- package/src/runtime/views/index.ts +7 -0
- package/src/runtime/views/overviewGrid.ts +106 -0
- package/src/runtime/views/presenterPreview.ts +27 -0
- package/src/runtime/virtual-modules.d.ts +98 -0
- package/src/theme/SPEC.md +179 -0
- package/src/theme/base.css +623 -0
- package/src/theme/bee.css +35 -0
- package/src/theme/clean.css +38 -0
- package/src/vite-plugin/SPEC.md +114 -0
- package/src/vite-plugin/component-doc-crawler.ts +350 -0
- package/src/vite-plugin/deck-loader.ts +148 -0
- package/src/vite-plugin/index.ts +373 -0
- package/src/vite-plugin/layout-demo-crawler.ts +802 -0
- package/src/vite-plugin/splitter.ts +353 -0
- package/src/vite-plugin/token-manifest.ts +163 -0
- package/src/vite-plugin/virtual-modules.ts +587 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deck — root presentation component.
|
|
3
|
+
*
|
|
4
|
+
* Renders ALL slides in the DOM simultaneously and shows the current one
|
|
5
|
+
* determined by the URL hash router. Non-current slides are hidden via
|
|
6
|
+
* `opacity: 0` + `visibility: hidden`; a 200ms CSS crossfade makes slide
|
|
7
|
+
* changes smooth. The `visibility` transition is delayed by 200ms on hide
|
|
8
|
+
* so the outgoing slide remains painted during the opacity fade-out,
|
|
9
|
+
* preventing a black flicker from the container background showing through.
|
|
10
|
+
*
|
|
11
|
+
* ### Phase 5 additions
|
|
12
|
+
* - Routes to `<PresenterView>` when hash is `#/presenter/…`
|
|
13
|
+
* - Overlay with `<OverviewView>` when overview mode is toggled
|
|
14
|
+
* - `<NavBar>` always present (auto-hides on desktop, visible on touch)
|
|
15
|
+
* - `useSwipeNav` for touch devices
|
|
16
|
+
* - `useSync` for BroadcastChannel audience-side sync
|
|
17
|
+
* - Manual color mode override (system / light / dark) via NavBar
|
|
18
|
+
*
|
|
19
|
+
* ### Viewport scaling
|
|
20
|
+
* The base canvas is 1920 × 1080 px; `transform: scale()` shrinks it
|
|
21
|
+
* uniformly to fit any screen size without distorting content.
|
|
22
|
+
*
|
|
23
|
+
* ### Architecture note
|
|
24
|
+
* Slide data (metadata, components, layout resolution) is now imported from
|
|
25
|
+
* `./slideData.ts` which is also used by PresenterView and OverviewView.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { config } from "virtual:honeydeck/config";
|
|
29
|
+
import {
|
|
30
|
+
useCallback,
|
|
31
|
+
useEffect,
|
|
32
|
+
useLayoutEffect,
|
|
33
|
+
useRef,
|
|
34
|
+
useState,
|
|
35
|
+
} from "react";
|
|
36
|
+
import {
|
|
37
|
+
applyHoneydeckColorMode,
|
|
38
|
+
resolveEffectiveColorMode,
|
|
39
|
+
} from "./colorMode.ts";
|
|
40
|
+
import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
|
|
41
|
+
import { ErrorBoundary } from "./components/ErrorBoundary.tsx";
|
|
42
|
+
import { NavBar } from "./components/NavBar.tsx";
|
|
43
|
+
import { SlideNumberBadge } from "./components/SlideNumberBadge.tsx";
|
|
44
|
+
import { rememberSlideRoute } from "./lastSlideRoute.ts";
|
|
45
|
+
import {
|
|
46
|
+
closeOverview,
|
|
47
|
+
toggleOverview as toggleOverviewRoute,
|
|
48
|
+
} from "./navigation.ts";
|
|
49
|
+
import { useRoute } from "./router.ts";
|
|
50
|
+
import {
|
|
51
|
+
BASE_HEIGHT,
|
|
52
|
+
BASE_WIDTH,
|
|
53
|
+
resolveLayout,
|
|
54
|
+
slideData,
|
|
55
|
+
} from "./slideData.ts";
|
|
56
|
+
import { useSync } from "./sync.ts";
|
|
57
|
+
import { TimelineProvider } from "./TimelineContext.tsx";
|
|
58
|
+
import { useKeyboardNav } from "./useKeyboardNav.ts";
|
|
59
|
+
import { useSwipeNav } from "./useSwipeNav.ts";
|
|
60
|
+
import { DocsView } from "./views/DocsView.tsx";
|
|
61
|
+
import { OverviewView } from "./views/OverviewView.tsx";
|
|
62
|
+
import { PresenterView } from "./views/PresenterView.tsx";
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Scale calculation
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compute the scale from the stage element's actual dimensions.
|
|
70
|
+
* The stage fills the viewport, so its size equals the viewport size.
|
|
71
|
+
*/
|
|
72
|
+
function calcScaleFromElement(el: HTMLElement | null): number | null {
|
|
73
|
+
if (!el?.isConnected || el.clientWidth <= 0 || el.clientHeight <= 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return Math.min(el.clientWidth / BASE_WIDTH, el.clientHeight / BASE_HEIGHT);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Component
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export function Deck() {
|
|
84
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
85
|
+
const [scale, setScale] = useState(1);
|
|
86
|
+
const [slideZoom, setSlideZoom] = useState(1);
|
|
87
|
+
const [slidePan, setSlidePan] = useState({ x: 0, y: 0 });
|
|
88
|
+
const [navBarToggleSignal, setNavBarToggleSignal] = useState(0);
|
|
89
|
+
const [slideTextSelectionEnabled, setSlideTextSelectionEnabled] =
|
|
90
|
+
useState(false);
|
|
91
|
+
// GAP-04: initialize colorMode from config.colorMode (spec: deck can pin mode)
|
|
92
|
+
const [colorMode, setColorMode] = useState<ColorMode>(() => {
|
|
93
|
+
const c = config.colorMode;
|
|
94
|
+
if (c === "light" || c === "dark") return c;
|
|
95
|
+
return "system";
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const route = useRoute();
|
|
99
|
+
const pointerLayout = usePointerLayout();
|
|
100
|
+
|
|
101
|
+
// ── All hooks MUST be called unconditionally (React rules of hooks) ────
|
|
102
|
+
// Previously these were guarded by early returns which violated hook rules.
|
|
103
|
+
// Now all hooks run on every render; reference/presenter modes simply ignore them.
|
|
104
|
+
|
|
105
|
+
// ── Color mode: apply data-honeydeck-color-mode to <html> ──────────────────
|
|
106
|
+
useLayoutEffect(() => {
|
|
107
|
+
function applyMode(darkFromSystem: boolean) {
|
|
108
|
+
applyHoneydeckColorMode(
|
|
109
|
+
resolveEffectiveColorMode(colorMode, darkFromSystem),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
114
|
+
applyMode(mq.matches);
|
|
115
|
+
|
|
116
|
+
const onChange = (e: MediaQueryListEvent) => applyMode(e.matches);
|
|
117
|
+
mq.addEventListener("change", onChange);
|
|
118
|
+
return () => mq.removeEventListener("change", onChange);
|
|
119
|
+
}, [colorMode]);
|
|
120
|
+
|
|
121
|
+
// ── Observe stage size → recalculate scale ─────────────────────────────
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (route.view !== "slide" && route.view !== "overview") return;
|
|
124
|
+
|
|
125
|
+
const el = stageRef.current;
|
|
126
|
+
if (!el) return;
|
|
127
|
+
|
|
128
|
+
function updateScale() {
|
|
129
|
+
const nextScale = calcScaleFromElement(el);
|
|
130
|
+
if (nextScale !== null) setScale(nextScale);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ro = new ResizeObserver(updateScale);
|
|
134
|
+
ro.observe(el);
|
|
135
|
+
updateScale();
|
|
136
|
+
return () => ro.disconnect();
|
|
137
|
+
}, [route.view]);
|
|
138
|
+
|
|
139
|
+
// ── Remember latest audience slide for returning from reference pages ───
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (route.view !== "slide") return;
|
|
142
|
+
|
|
143
|
+
rememberSlideRoute({
|
|
144
|
+
view: "slide",
|
|
145
|
+
slide: Math.max(1, Math.min(route.slide, slideData.length || 1)),
|
|
146
|
+
step: Math.max(0, route.step),
|
|
147
|
+
});
|
|
148
|
+
}, [route]);
|
|
149
|
+
|
|
150
|
+
// ── BroadcastChannel: audience side (listen for presenter navigation) ──
|
|
151
|
+
useSync({
|
|
152
|
+
enabled: route.view === "slide" || route.view === "overview",
|
|
153
|
+
isPresenter: false,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const resetZoom = useCallback(() => {
|
|
157
|
+
setSlideZoom(1);
|
|
158
|
+
setSlidePan({ x: 0, y: 0 });
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
function clampPan(nextPan: { x: number; y: number }, zoom = slideZoom) {
|
|
162
|
+
const maxX = (BASE_WIDTH * scale * Math.max(0, zoom - 1)) / 2;
|
|
163
|
+
const maxY = (BASE_HEIGHT * scale * Math.max(0, zoom - 1)) / 2;
|
|
164
|
+
return {
|
|
165
|
+
x: Math.max(-maxX, Math.min(maxX, nextPan.x)),
|
|
166
|
+
y: Math.max(-maxY, Math.min(maxY, nextPan.y)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Touch swipe/tap/pinch navigation ────────────────────────────────────
|
|
171
|
+
useSwipeNav({
|
|
172
|
+
enabled: route.view === "slide" && !slideTextSelectionEnabled,
|
|
173
|
+
zoom: slideZoom,
|
|
174
|
+
boundaryRef: stageRef,
|
|
175
|
+
onToggleNavBar: () => setNavBarToggleSignal((value) => value + 1),
|
|
176
|
+
onZoomChange: (zoom) => {
|
|
177
|
+
setSlideZoom(zoom);
|
|
178
|
+
setSlidePan((pan) => clampPan(pan, zoom));
|
|
179
|
+
},
|
|
180
|
+
onResetZoom: resetZoom,
|
|
181
|
+
onPanBy: ({ dx, dy }) => {
|
|
182
|
+
setSlidePan((pan) => clampPan({ x: pan.x + dx, y: pan.y + dy }));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const getStepCount = useCallback(
|
|
187
|
+
(slideIndex: number): number => slideData[slideIndex]?.stepCount ?? 0,
|
|
188
|
+
[],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const isOverview = route.view === "overview";
|
|
192
|
+
const toggleOverview = useCallback(
|
|
193
|
+
() =>
|
|
194
|
+
toggleOverviewRoute(route, {
|
|
195
|
+
slideCount: slideData.length,
|
|
196
|
+
getStepCount,
|
|
197
|
+
}),
|
|
198
|
+
[route, getStepCount],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
useKeyboardNav({
|
|
202
|
+
enabled: route.view === "slide",
|
|
203
|
+
slideCount: slideData.length,
|
|
204
|
+
getStepCount,
|
|
205
|
+
onToggleOverview: toggleOverview,
|
|
206
|
+
isOverview,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const routePositionRef = useRef(`${route.slide}/${route.step}`);
|
|
210
|
+
// Slide/step navigation resets Honeydeck-controlled slide zoom.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const routePosition = `${route.slide}/${route.step}`;
|
|
213
|
+
if (routePositionRef.current === routePosition) return;
|
|
214
|
+
routePositionRef.current = routePosition;
|
|
215
|
+
resetZoom();
|
|
216
|
+
}, [resetZoom, route.slide, route.step]);
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
setSlidePan((pan) => {
|
|
220
|
+
const maxX = (BASE_WIDTH * scale * Math.max(0, slideZoom - 1)) / 2;
|
|
221
|
+
const maxY = (BASE_HEIGHT * scale * Math.max(0, slideZoom - 1)) / 2;
|
|
222
|
+
return {
|
|
223
|
+
x: Math.max(-maxX, Math.min(maxX, pan.x)),
|
|
224
|
+
y: Math.max(-maxY, Math.min(maxY, pan.y)),
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}, [scale, slideZoom]);
|
|
228
|
+
|
|
229
|
+
// ── Reference mode: delegate to DocsView ─────────────────────────────
|
|
230
|
+
if (route.view === "kit") {
|
|
231
|
+
return (
|
|
232
|
+
<DocsView
|
|
233
|
+
tab={route.kitTab}
|
|
234
|
+
colorMode={colorMode}
|
|
235
|
+
onSetColorMode={setColorMode}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Presenter mode: delegate to PresenterView ──────────────────────────
|
|
241
|
+
if (route.view === "presenter") {
|
|
242
|
+
return <PresenterView />;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Whether slide transitions are enabled (can be disabled via deck frontmatter)
|
|
246
|
+
const enableTransition = config.transition !== false;
|
|
247
|
+
|
|
248
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
249
|
+
// Guard: no slides
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
if (slideData.length === 0) {
|
|
253
|
+
return (
|
|
254
|
+
<div className="fixed inset-0 flex items-center justify-center bg-void text-white font-sans text-3xl">
|
|
255
|
+
✏️ No slides found — add content to <code>deck.mdx</code>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const currentSlide = Math.max(1, Math.min(route.slide, slideData.length));
|
|
261
|
+
const currentStep = Math.max(0, route.step);
|
|
262
|
+
const controlRoute =
|
|
263
|
+
route.view === "slide" || route.view === "overview"
|
|
264
|
+
? { ...route, slide: currentSlide, step: currentStep }
|
|
265
|
+
: route;
|
|
266
|
+
const slideTransform = `translate(${slidePan.x}px, ${slidePan.y}px) scale(${scale * slideZoom})`;
|
|
267
|
+
const showSlideNumbers = config.showSlideNumbers === true;
|
|
268
|
+
const disableSlideTextSelection =
|
|
269
|
+
route.view === "slide" &&
|
|
270
|
+
pointerLayout.isTouchDevice &&
|
|
271
|
+
!slideTextSelectionEnabled;
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Render
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div className="fixed inset-0 overflow-hidden bg-black">
|
|
279
|
+
{/* ── Sizing container: fills viewport for scale calc ──────── */}
|
|
280
|
+
<div
|
|
281
|
+
ref={stageRef}
|
|
282
|
+
className={`absolute inset-0 ${disableSlideTextSelection ? "select-none" : ""}`}
|
|
283
|
+
>
|
|
284
|
+
{/* ── Stage backdrop: themed bg at slide size, prevents flicker ──── */}
|
|
285
|
+
<div
|
|
286
|
+
aria-hidden="true"
|
|
287
|
+
className="absolute inset-0 flex items-center justify-center"
|
|
288
|
+
>
|
|
289
|
+
<div
|
|
290
|
+
className="shrink-0 bg-background"
|
|
291
|
+
style={{
|
|
292
|
+
width: BASE_WIDTH,
|
|
293
|
+
height: BASE_HEIGHT,
|
|
294
|
+
transform: `scale(${scale})`,
|
|
295
|
+
transformOrigin: "center center",
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* ── All slides (only current is visible) ──────────────────────── */}
|
|
301
|
+
{slideData.map((data, i) => {
|
|
302
|
+
const slideNumber = i + 1;
|
|
303
|
+
const isCurrent = slideNumber === currentSlide;
|
|
304
|
+
const { Component, stepCount, title, frontmatter, layoutName } = data;
|
|
305
|
+
const LayoutComponent = resolveLayout(layoutName);
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div
|
|
309
|
+
key={data.id}
|
|
310
|
+
aria-hidden={!isCurrent}
|
|
311
|
+
className={`absolute inset-0 flex items-center justify-center ${
|
|
312
|
+
isCurrent
|
|
313
|
+
? "opacity-100 visible pointer-events-auto z-1"
|
|
314
|
+
: "opacity-0 invisible pointer-events-none z-0"
|
|
315
|
+
}`}
|
|
316
|
+
style={{
|
|
317
|
+
transition: enableTransition
|
|
318
|
+
? `opacity 200ms ease, visibility 0s ${isCurrent ? "0s" : "200ms"}`
|
|
319
|
+
: "none",
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
<TimelineProvider
|
|
323
|
+
stepIndex={isCurrent ? currentStep : 0}
|
|
324
|
+
stepCount={stepCount}
|
|
325
|
+
>
|
|
326
|
+
<div
|
|
327
|
+
className="honeydeck-slide-canvas shrink-0 relative overflow-hidden box-border"
|
|
328
|
+
style={{
|
|
329
|
+
width: BASE_WIDTH,
|
|
330
|
+
height: BASE_HEIGHT,
|
|
331
|
+
transform: slideTransform,
|
|
332
|
+
transformOrigin: "center center",
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
<ErrorBoundary slideNumber={slideNumber}>
|
|
336
|
+
<LayoutComponent
|
|
337
|
+
title={title || null}
|
|
338
|
+
frontmatter={frontmatter}
|
|
339
|
+
rawChildren={<Component />}
|
|
340
|
+
>
|
|
341
|
+
<Component />
|
|
342
|
+
</LayoutComponent>
|
|
343
|
+
</ErrorBoundary>
|
|
344
|
+
</div>
|
|
345
|
+
</TimelineProvider>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
|
|
350
|
+
{showSlideNumbers && (
|
|
351
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
|
|
352
|
+
<div
|
|
353
|
+
className="honeydeck-slide-number-layer shrink-0 relative"
|
|
354
|
+
style={{
|
|
355
|
+
width: BASE_WIDTH,
|
|
356
|
+
height: BASE_HEIGHT,
|
|
357
|
+
transform: slideTransform,
|
|
358
|
+
transformOrigin: "center center",
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
<SlideNumberBadge slide={currentSlide} />
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
{/* ── Overview overlay ──────────────────────────────────────────── */}
|
|
368
|
+
{isOverview && (
|
|
369
|
+
<OverviewView
|
|
370
|
+
currentSlide={currentSlide}
|
|
371
|
+
currentStep={currentStep}
|
|
372
|
+
onClose={() =>
|
|
373
|
+
closeOverview(controlRoute, {
|
|
374
|
+
slideCount: slideData.length,
|
|
375
|
+
getStepCount,
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
/>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{/* ── Navigation bar ────────────────────────────────────────────── */}
|
|
382
|
+
{/* GAP-06: showSlideNumbers wired from config */}
|
|
383
|
+
{!isOverview && (
|
|
384
|
+
<NavBarWithHover
|
|
385
|
+
route={controlRoute}
|
|
386
|
+
isOverview={isOverview}
|
|
387
|
+
colorMode={colorMode}
|
|
388
|
+
onToggleOverview={toggleOverview}
|
|
389
|
+
onSetColorMode={setColorMode}
|
|
390
|
+
isZoomed={slideZoom > 1}
|
|
391
|
+
onResetZoom={resetZoom}
|
|
392
|
+
toggleSignal={navBarToggleSignal}
|
|
393
|
+
showTextSelectionToggle={pointerLayout.isTouchDevice}
|
|
394
|
+
isTextSelectionEnabled={slideTextSelectionEnabled}
|
|
395
|
+
onToggleTextSelection={() =>
|
|
396
|
+
setSlideTextSelectionEnabled((value) => !value)
|
|
397
|
+
}
|
|
398
|
+
/>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function usePointerLayout() {
|
|
405
|
+
const [state, setState] = useState(() => {
|
|
406
|
+
if (typeof window === "undefined") {
|
|
407
|
+
return { isTouchDevice: false, isPortrait: false };
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
isTouchDevice: window.matchMedia("(pointer: coarse)").matches,
|
|
411
|
+
isPortrait: window.matchMedia("(orientation: portrait)").matches,
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
const pointerQuery = window.matchMedia("(pointer: coarse)");
|
|
417
|
+
const portraitQuery = window.matchMedia("(orientation: portrait)");
|
|
418
|
+
const update = () => {
|
|
419
|
+
setState({
|
|
420
|
+
isTouchDevice: pointerQuery.matches,
|
|
421
|
+
isPortrait: portraitQuery.matches,
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
update();
|
|
425
|
+
pointerQuery.addEventListener("change", update);
|
|
426
|
+
portraitQuery.addEventListener("change", update);
|
|
427
|
+
window.addEventListener("resize", update);
|
|
428
|
+
return () => {
|
|
429
|
+
pointerQuery.removeEventListener("change", update);
|
|
430
|
+
portraitQuery.removeEventListener("change", update);
|
|
431
|
+
window.removeEventListener("resize", update);
|
|
432
|
+
};
|
|
433
|
+
}, []);
|
|
434
|
+
|
|
435
|
+
return state;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// NavBarWithHover — handles desktop hover / touch visibility
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
function NavBarWithHover(props: {
|
|
443
|
+
route: ReturnType<typeof useRoute>;
|
|
444
|
+
isOverview: boolean;
|
|
445
|
+
colorMode: ColorMode;
|
|
446
|
+
onToggleOverview: () => void;
|
|
447
|
+
onSetColorMode: (m: ColorMode) => void;
|
|
448
|
+
isZoomed: boolean;
|
|
449
|
+
onResetZoom: () => void;
|
|
450
|
+
toggleSignal: number;
|
|
451
|
+
showTextSelectionToggle: boolean;
|
|
452
|
+
isTextSelectionEnabled: boolean;
|
|
453
|
+
onToggleTextSelection: () => void;
|
|
454
|
+
}) {
|
|
455
|
+
const [hovered, setHovered] = useState(false);
|
|
456
|
+
const [touchVisible, setTouchVisible] = useState(false);
|
|
457
|
+
const { isTouchDevice, isPortrait } = usePointerLayout();
|
|
458
|
+
const hideTimerRef = useRef<number | null>(null);
|
|
459
|
+
const lastToggleSignalRef = useRef(props.toggleSignal);
|
|
460
|
+
|
|
461
|
+
const visible = isTouchDevice
|
|
462
|
+
? isPortrait || touchVisible || props.isTextSelectionEnabled
|
|
463
|
+
: hovered;
|
|
464
|
+
|
|
465
|
+
const clearHideTimer = useCallback(() => {
|
|
466
|
+
if (hideTimerRef.current !== null) {
|
|
467
|
+
window.clearTimeout(hideTimerRef.current);
|
|
468
|
+
hideTimerRef.current = null;
|
|
469
|
+
}
|
|
470
|
+
}, []);
|
|
471
|
+
|
|
472
|
+
const scheduleHide = useCallback(() => {
|
|
473
|
+
if (!isTouchDevice || isPortrait) return;
|
|
474
|
+
clearHideTimer();
|
|
475
|
+
hideTimerRef.current = window.setTimeout(
|
|
476
|
+
() => setTouchVisible(false),
|
|
477
|
+
3000,
|
|
478
|
+
);
|
|
479
|
+
}, [clearHideTimer, isPortrait, isTouchDevice]);
|
|
480
|
+
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
if (lastToggleSignalRef.current === props.toggleSignal) return;
|
|
483
|
+
lastToggleSignalRef.current = props.toggleSignal;
|
|
484
|
+
if (!isTouchDevice || isPortrait) return;
|
|
485
|
+
setTouchVisible((value) => !value);
|
|
486
|
+
}, [props.toggleSignal, isPortrait, isTouchDevice]);
|
|
487
|
+
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
if (touchVisible) scheduleHide();
|
|
490
|
+
return clearHideTimer;
|
|
491
|
+
}, [clearHideTimer, scheduleHide, touchVisible]);
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<>
|
|
495
|
+
{/* Transparent hover zone at bottom of screen */}
|
|
496
|
+
{!isTouchDevice && (
|
|
497
|
+
<div
|
|
498
|
+
aria-hidden="true"
|
|
499
|
+
role="presentation"
|
|
500
|
+
className="fixed bottom-0 left-0 right-0 h-20 z-40 pointer-events-auto"
|
|
501
|
+
onMouseEnter={() => setHovered(true)}
|
|
502
|
+
onMouseLeave={() => setHovered(false)}
|
|
503
|
+
/>
|
|
504
|
+
)}
|
|
505
|
+
|
|
506
|
+
{/* NavBar with opacity transition */}
|
|
507
|
+
<nav
|
|
508
|
+
aria-label="Deck controls"
|
|
509
|
+
className={`transition-opacity duration-200 ease-out z-[150] ${
|
|
510
|
+
visible
|
|
511
|
+
? "opacity-100 pointer-events-auto"
|
|
512
|
+
: "opacity-0 pointer-events-none"
|
|
513
|
+
}`}
|
|
514
|
+
onMouseEnter={() => !isTouchDevice && setHovered(true)}
|
|
515
|
+
onMouseLeave={() => !isTouchDevice && setHovered(false)}
|
|
516
|
+
onPointerDown={scheduleHide}
|
|
517
|
+
>
|
|
518
|
+
<NavBar
|
|
519
|
+
route={props.route}
|
|
520
|
+
isOverview={props.isOverview}
|
|
521
|
+
colorMode={props.colorMode}
|
|
522
|
+
onToggleOverview={props.onToggleOverview}
|
|
523
|
+
onSetColorMode={props.onSetColorMode}
|
|
524
|
+
isZoomed={props.isZoomed}
|
|
525
|
+
onResetZoom={props.onResetZoom}
|
|
526
|
+
showTextSelectionToggle={props.showTextSelectionToggle}
|
|
527
|
+
isTextSelectionEnabled={props.isTextSelectionEnabled}
|
|
528
|
+
onToggleTextSelection={props.onToggleTextSelection}
|
|
529
|
+
/>
|
|
530
|
+
</nav>
|
|
531
|
+
</>
|
|
532
|
+
);
|
|
533
|
+
}
|