@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,257 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useRef } from "react";
|
|
2
|
+
import { shouldDeckOwnTouchGesture } from "./inputOwnership.ts";
|
|
3
|
+
import {
|
|
4
|
+
nextSlide,
|
|
5
|
+
nextStep,
|
|
6
|
+
previousSlide,
|
|
7
|
+
previousStep,
|
|
8
|
+
} from "./navigation.ts";
|
|
9
|
+
import type { Route } from "./router.ts";
|
|
10
|
+
import { parseHash } from "./router.ts";
|
|
11
|
+
import { slideData } from "./slideData.ts";
|
|
12
|
+
|
|
13
|
+
export type PanDelta = { dx: number; dy: number };
|
|
14
|
+
|
|
15
|
+
export type UseSwipeNavOptions = {
|
|
16
|
+
/** Whether touch navigation should be active. */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
/** Minimum swipe distance in px before the gesture fires. @default 50 */
|
|
19
|
+
threshold?: number;
|
|
20
|
+
/** Current Honeydeck slide zoom. */
|
|
21
|
+
zoom?: number;
|
|
22
|
+
/** Gesture boundary. Scrollable ancestors are detected before this element. */
|
|
23
|
+
boundaryRef?: RefObject<Element | null>;
|
|
24
|
+
/** Called when the center tap zone is tapped. */
|
|
25
|
+
onToggleNavBar?: () => void;
|
|
26
|
+
/** Called when pinch/drag changes slide zoom. */
|
|
27
|
+
onZoomChange?: (zoom: number) => void;
|
|
28
|
+
/** Called when zoom should reset to 1. */
|
|
29
|
+
onResetZoom?: () => void;
|
|
30
|
+
/** Called when one-finger drag pans a zoomed slide. */
|
|
31
|
+
onPanBy?: (delta: PanDelta) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type TouchGestureState =
|
|
35
|
+
| null
|
|
36
|
+
| {
|
|
37
|
+
kind: "single";
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
lastX: number;
|
|
41
|
+
lastY: number;
|
|
42
|
+
owned: boolean;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
kind: "pinch";
|
|
46
|
+
distance: number;
|
|
47
|
+
initialZoom: number;
|
|
48
|
+
lastFactor: number;
|
|
49
|
+
lastZoom: number;
|
|
50
|
+
owned: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const TAP_MAX_DISTANCE = 10;
|
|
54
|
+
const MIN_ZOOM = 1;
|
|
55
|
+
const MAX_ZOOM = 5;
|
|
56
|
+
const ZOOM_RESET_THRESHOLD = 1.05;
|
|
57
|
+
|
|
58
|
+
export function useSwipeNav({
|
|
59
|
+
enabled = true,
|
|
60
|
+
threshold = 50,
|
|
61
|
+
zoom = 1,
|
|
62
|
+
boundaryRef,
|
|
63
|
+
onToggleNavBar,
|
|
64
|
+
onZoomChange,
|
|
65
|
+
onResetZoom,
|
|
66
|
+
onPanBy,
|
|
67
|
+
}: UseSwipeNavOptions = {}): void {
|
|
68
|
+
const gestureRef = useRef<TouchGestureState>(null);
|
|
69
|
+
const zoomRef = useRef(zoom);
|
|
70
|
+
zoomRef.current = zoom;
|
|
71
|
+
|
|
72
|
+
const callbacksRef = useRef({
|
|
73
|
+
onToggleNavBar,
|
|
74
|
+
onZoomChange,
|
|
75
|
+
onResetZoom,
|
|
76
|
+
onPanBy,
|
|
77
|
+
});
|
|
78
|
+
callbacksRef.current = {
|
|
79
|
+
onToggleNavBar,
|
|
80
|
+
onZoomChange,
|
|
81
|
+
onResetZoom,
|
|
82
|
+
onPanBy,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!enabled) return;
|
|
87
|
+
|
|
88
|
+
function getBoundary() {
|
|
89
|
+
return boundaryRef?.current ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onTouchStart(e: TouchEvent) {
|
|
93
|
+
const owned = shouldDeckOwnTouchGesture(e.target, getBoundary());
|
|
94
|
+
|
|
95
|
+
if (e.touches.length >= 2) {
|
|
96
|
+
const distance = getTouchDistance(e.touches[0], e.touches[1]);
|
|
97
|
+
gestureRef.current = {
|
|
98
|
+
kind: "pinch",
|
|
99
|
+
distance,
|
|
100
|
+
initialZoom: zoomRef.current,
|
|
101
|
+
lastFactor: 1,
|
|
102
|
+
lastZoom: zoomRef.current,
|
|
103
|
+
owned,
|
|
104
|
+
};
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const t = e.touches[0];
|
|
109
|
+
if (!t) return;
|
|
110
|
+
gestureRef.current = {
|
|
111
|
+
kind: "single",
|
|
112
|
+
x: t.clientX,
|
|
113
|
+
y: t.clientY,
|
|
114
|
+
lastX: t.clientX,
|
|
115
|
+
lastY: t.clientY,
|
|
116
|
+
owned,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onTouchMove(e: TouchEvent) {
|
|
121
|
+
const state = gestureRef.current;
|
|
122
|
+
if (!state?.owned) return;
|
|
123
|
+
|
|
124
|
+
if (state.kind === "pinch" && e.touches.length >= 2) {
|
|
125
|
+
const distance = getTouchDistance(e.touches[0], e.touches[1]);
|
|
126
|
+
const factor = distance / state.distance;
|
|
127
|
+
state.lastFactor = factor;
|
|
128
|
+
|
|
129
|
+
const nextZoom = clampZoom(state.initialZoom * factor);
|
|
130
|
+
state.lastZoom = nextZoom;
|
|
131
|
+
if (nextZoom >= MIN_ZOOM) {
|
|
132
|
+
callbacksRef.current.onZoomChange?.(nextZoom);
|
|
133
|
+
}
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (state.kind === "single" && zoomRef.current > 1) {
|
|
139
|
+
const t = e.touches[0];
|
|
140
|
+
if (!t) return;
|
|
141
|
+
callbacksRef.current.onPanBy?.({
|
|
142
|
+
dx: t.clientX - state.lastX,
|
|
143
|
+
dy: t.clientY - state.lastY,
|
|
144
|
+
});
|
|
145
|
+
state.lastX = t.clientX;
|
|
146
|
+
state.lastY = t.clientY;
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onTouchEnd(e: TouchEvent) {
|
|
152
|
+
const state = gestureRef.current;
|
|
153
|
+
if (!state) return;
|
|
154
|
+
gestureRef.current = null;
|
|
155
|
+
|
|
156
|
+
if (!state.owned) return;
|
|
157
|
+
|
|
158
|
+
if (state.kind === "pinch") {
|
|
159
|
+
const route = parseCurrentRoute();
|
|
160
|
+
if (route.view !== "slide") return;
|
|
161
|
+
|
|
162
|
+
if (state.lastZoom < ZOOM_RESET_THRESHOLD) {
|
|
163
|
+
callbacksRef.current.onResetZoom?.();
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const t = e.changedTouches[0];
|
|
169
|
+
if (!t) return;
|
|
170
|
+
|
|
171
|
+
const dx = t.clientX - state.x;
|
|
172
|
+
const dy = t.clientY - state.y;
|
|
173
|
+
const adx = Math.abs(dx);
|
|
174
|
+
const ady = Math.abs(dy);
|
|
175
|
+
|
|
176
|
+
if (adx <= TAP_MAX_DISTANCE && ady <= TAP_MAX_DISTANCE) {
|
|
177
|
+
handleTapZone(t.clientX, t.clientY, zoomRef.current > 1);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (zoomRef.current > 1) return;
|
|
182
|
+
if (adx < threshold && ady < threshold) return;
|
|
183
|
+
|
|
184
|
+
const route = parseCurrentRoute();
|
|
185
|
+
if (route.view !== "slide" && route.view !== "presenter") return;
|
|
186
|
+
|
|
187
|
+
const options = {
|
|
188
|
+
slideCount: slideData.length,
|
|
189
|
+
getStepCount: (slideIndex: number) =>
|
|
190
|
+
slideData[slideIndex]?.stepCount ?? 0,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (adx >= ady) {
|
|
194
|
+
if (dx < 0) nextStep(route, options);
|
|
195
|
+
else previousStep(route, options);
|
|
196
|
+
} else if (dy < 0) {
|
|
197
|
+
nextSlide(route, options);
|
|
198
|
+
} else {
|
|
199
|
+
previousSlide(route, options);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function handleTapZone(x: number, y: number, isZoomed: boolean) {
|
|
204
|
+
const width = window.innerWidth || 1;
|
|
205
|
+
const height = window.innerHeight || 1;
|
|
206
|
+
const xRatio = x / width;
|
|
207
|
+
const yRatio = y / height;
|
|
208
|
+
|
|
209
|
+
const isCenter =
|
|
210
|
+
yRatio >= 0.25 && yRatio <= 0.75 && xRatio >= 0.35 && xRatio <= 0.65;
|
|
211
|
+
if (isCenter) {
|
|
212
|
+
callbacksRef.current.onToggleNavBar?.();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (isZoomed) return;
|
|
217
|
+
|
|
218
|
+
const route = parseCurrentRoute();
|
|
219
|
+
if (route.view !== "slide" && route.view !== "presenter") return;
|
|
220
|
+
const options = {
|
|
221
|
+
slideCount: slideData.length,
|
|
222
|
+
getStepCount: (slideIndex: number) =>
|
|
223
|
+
slideData[slideIndex]?.stepCount ?? 0,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (yRatio < 0.25) previousSlide(route, options);
|
|
227
|
+
else if (yRatio > 0.75) nextSlide(route, options);
|
|
228
|
+
else if (xRatio < 0.35) previousStep(route, options);
|
|
229
|
+
else if (xRatio > 0.65) nextStep(route, options);
|
|
230
|
+
else callbacksRef.current.onToggleNavBar?.();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
window.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
234
|
+
window.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
235
|
+
window.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
236
|
+
window.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
|
237
|
+
|
|
238
|
+
return () => {
|
|
239
|
+
window.removeEventListener("touchstart", onTouchStart);
|
|
240
|
+
window.removeEventListener("touchmove", onTouchMove);
|
|
241
|
+
window.removeEventListener("touchend", onTouchEnd);
|
|
242
|
+
window.removeEventListener("touchcancel", onTouchEnd);
|
|
243
|
+
};
|
|
244
|
+
}, [enabled, threshold, boundaryRef]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getTouchDistance(a: Touch, b: Touch): number {
|
|
248
|
+
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function clampZoom(value: number): number {
|
|
252
|
+
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseCurrentRoute(): Route {
|
|
256
|
+
return parseHash(location.hash);
|
|
257
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocsView — built-in runtime reference for the active Honeydeck setup.
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* /#/theme
|
|
6
|
+
* /#/layouts
|
|
7
|
+
* /#/components
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef } from "react";
|
|
11
|
+
import type { ColorMode } from "../components/ColorModeCycleButton.tsx";
|
|
12
|
+
import { isEditableKeyboardTarget } from "../keyboardTarget.ts";
|
|
13
|
+
import { getRememberedSlideRoute } from "../lastSlideRoute.ts";
|
|
14
|
+
import type { KitTab } from "../router.ts";
|
|
15
|
+
import { navigate } from "../router.ts";
|
|
16
|
+
import { ComponentsTab } from "./docs/ComponentsTab.tsx";
|
|
17
|
+
import { DocsHeader } from "./docs/DocsHeader.tsx";
|
|
18
|
+
import { LayoutsTab } from "./docs/LayoutsTab.tsx";
|
|
19
|
+
import { ThemeTab } from "./docs/ThemeTab.tsx";
|
|
20
|
+
|
|
21
|
+
export type DocsViewProps = {
|
|
22
|
+
tab?: KitTab;
|
|
23
|
+
colorMode: ColorMode;
|
|
24
|
+
onSetColorMode: (mode: ColorMode) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function DocsView({
|
|
28
|
+
tab = "theme",
|
|
29
|
+
colorMode,
|
|
30
|
+
onSetColorMode,
|
|
31
|
+
}: DocsViewProps) {
|
|
32
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
void tab;
|
|
36
|
+
scrollRef.current?.scrollTo({ top: 0 });
|
|
37
|
+
}, [tab]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
41
|
+
if (event.key !== "Escape") return;
|
|
42
|
+
if (isEditableKeyboardTarget(event.target)) return;
|
|
43
|
+
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
navigate(getRememberedSlideRoute());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
49
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
ref={scrollRef}
|
|
55
|
+
className="fixed inset-0 overflow-auto bg-background text-foreground font-sans"
|
|
56
|
+
>
|
|
57
|
+
<DocsHeader
|
|
58
|
+
tab={tab}
|
|
59
|
+
colorMode={colorMode}
|
|
60
|
+
onSetColorMode={onSetColorMode}
|
|
61
|
+
/>
|
|
62
|
+
|
|
63
|
+
<main className="mx-auto max-w-7xl px-5 py-8 sm:px-8">
|
|
64
|
+
{tab === "layouts" ? (
|
|
65
|
+
<LayoutsTab />
|
|
66
|
+
) : tab === "components" ? (
|
|
67
|
+
<ComponentsTab />
|
|
68
|
+
) : (
|
|
69
|
+
<ThemeTab />
|
|
70
|
+
)}
|
|
71
|
+
</main>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { navigate } from "../router.ts";
|
|
9
|
+
import { SlideCanvas } from "../SlideCanvas.tsx";
|
|
10
|
+
import { BASE_HEIGHT, BASE_WIDTH, slideData } from "../slideData.ts";
|
|
11
|
+
import {
|
|
12
|
+
getOverviewGridColumnCount,
|
|
13
|
+
getOverviewGridSelectionMove,
|
|
14
|
+
} from "./overviewGrid.ts";
|
|
15
|
+
|
|
16
|
+
const DESKTOP_THUMB_W = 360;
|
|
17
|
+
const SELECTION_SCROLL_MARGIN = 96;
|
|
18
|
+
const SELECTION_SCROLL_DURATION_MS = 180;
|
|
19
|
+
const MOBILE_COLUMNS = 2;
|
|
20
|
+
const MOBILE_PADDING = 16;
|
|
21
|
+
const MOBILE_GAP = 12;
|
|
22
|
+
|
|
23
|
+
type BoundaryFeedback = {
|
|
24
|
+
direction: "up" | "down";
|
|
25
|
+
key: number;
|
|
26
|
+
selected: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type SelectionState = {
|
|
30
|
+
selected: number;
|
|
31
|
+
boundaryFeedback: BoundaryFeedback | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type SelectionSource = "keyboard" | "direct";
|
|
35
|
+
|
|
36
|
+
export type OverviewViewProps = {
|
|
37
|
+
/** 1-based number of the slide encoded by the overview route. */
|
|
38
|
+
currentSlide: number;
|
|
39
|
+
/** 0-based step remembered by the overview route for returning to slides. */
|
|
40
|
+
currentStep: number;
|
|
41
|
+
/** Called when the overview should close. */
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function OverviewView({
|
|
46
|
+
currentSlide,
|
|
47
|
+
currentStep,
|
|
48
|
+
onClose,
|
|
49
|
+
}: OverviewViewProps) {
|
|
50
|
+
void currentStep;
|
|
51
|
+
const [selectionState, setSelectionState] = useState<SelectionState>({
|
|
52
|
+
selected: Math.max(0, currentSlide - 1),
|
|
53
|
+
boundaryFeedback: null,
|
|
54
|
+
});
|
|
55
|
+
const { selected, boundaryFeedback } = selectionState;
|
|
56
|
+
const [viewportWidth, setViewportWidth] = useState(() =>
|
|
57
|
+
typeof window === "undefined" ? 1024 : window.innerWidth,
|
|
58
|
+
);
|
|
59
|
+
const isMobile = useOverviewMobile();
|
|
60
|
+
const selectionSourceRef = useRef<SelectionSource>("direct");
|
|
61
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
63
|
+
const selectedRef = useRef<HTMLDivElement | null>(null);
|
|
64
|
+
const colsRef = useRef(1);
|
|
65
|
+
const total = slideData.length;
|
|
66
|
+
|
|
67
|
+
const gap = isMobile ? MOBILE_GAP : 48;
|
|
68
|
+
const thumbW = isMobile
|
|
69
|
+
? Math.max(
|
|
70
|
+
1,
|
|
71
|
+
(viewportWidth - MOBILE_PADDING * 2 - gap * (MOBILE_COLUMNS - 1)) /
|
|
72
|
+
MOBILE_COLUMNS,
|
|
73
|
+
)
|
|
74
|
+
: DESKTOP_THUMB_W;
|
|
75
|
+
const canvasW = thumbW;
|
|
76
|
+
const canvasH = canvasW * (BASE_HEIGHT / BASE_WIDTH);
|
|
77
|
+
const thumbScale = canvasW / BASE_WIDTH;
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
containerRef.current?.focus();
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const routeSelected = Math.max(0, currentSlide - 1);
|
|
85
|
+
selectionSourceRef.current = "direct";
|
|
86
|
+
setSelectionState({ selected: routeSelected, boundaryFeedback: null });
|
|
87
|
+
}, [currentSlide]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const update = () => setViewportWidth(window.innerWidth);
|
|
91
|
+
update();
|
|
92
|
+
window.addEventListener("resize", update);
|
|
93
|
+
return () => window.removeEventListener("resize", update);
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const container = containerRef.current;
|
|
98
|
+
const selectedElement = selectedRef.current;
|
|
99
|
+
if (selected < 0 || !container || !selectedElement) return;
|
|
100
|
+
|
|
101
|
+
const scrollContainer = container;
|
|
102
|
+
const startTop = scrollContainer.scrollTop;
|
|
103
|
+
const targetTop = getScrollTopForNearestElement(
|
|
104
|
+
scrollContainer,
|
|
105
|
+
selectedElement,
|
|
106
|
+
SELECTION_SCROLL_MARGIN,
|
|
107
|
+
);
|
|
108
|
+
const distance = targetTop - startTop;
|
|
109
|
+
|
|
110
|
+
if (Math.abs(distance) < 1 || getReducedMotionPreference()) {
|
|
111
|
+
container.scrollTop = targetTop;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const startTime = performance.now();
|
|
116
|
+
let animationFrame = 0;
|
|
117
|
+
|
|
118
|
+
function animate(now: number) {
|
|
119
|
+
const progress = Math.min(
|
|
120
|
+
1,
|
|
121
|
+
(now - startTime) / SELECTION_SCROLL_DURATION_MS,
|
|
122
|
+
);
|
|
123
|
+
scrollContainer.scrollTop = startTop + distance * easeOutCubic(progress);
|
|
124
|
+
|
|
125
|
+
if (progress < 1) animationFrame = requestAnimationFrame(animate);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
animationFrame = requestAnimationFrame(animate);
|
|
129
|
+
return () => cancelAnimationFrame(animationFrame);
|
|
130
|
+
}, [selected]);
|
|
131
|
+
|
|
132
|
+
const jumpTo = useCallback((index: number) => {
|
|
133
|
+
navigate({ view: "slide", slide: index + 1, step: 0 });
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
const updateColumnCount = useCallback(() => {
|
|
137
|
+
colsRef.current = getOverviewGridColumnCount(gridRef.current);
|
|
138
|
+
return colsRef.current;
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
updateColumnCount();
|
|
143
|
+
|
|
144
|
+
const grid = gridRef.current;
|
|
145
|
+
if (!grid) return;
|
|
146
|
+
|
|
147
|
+
if (typeof ResizeObserver === "undefined") {
|
|
148
|
+
window.addEventListener("resize", updateColumnCount);
|
|
149
|
+
return () => window.removeEventListener("resize", updateColumnCount);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const resizeObserver = new ResizeObserver(() => updateColumnCount());
|
|
153
|
+
resizeObserver.observe(grid);
|
|
154
|
+
return () => resizeObserver.disconnect();
|
|
155
|
+
}, [updateColumnCount]);
|
|
156
|
+
|
|
157
|
+
function setSelectedFrom(source: SelectionSource, index: number) {
|
|
158
|
+
selectionSourceRef.current = source;
|
|
159
|
+
setSelectionState({ selected: index, boundaryFeedback: null });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function moveSelection(
|
|
163
|
+
direction: Parameters<typeof getOverviewGridSelectionMove>[3],
|
|
164
|
+
) {
|
|
165
|
+
const columns = updateColumnCount();
|
|
166
|
+
selectionSourceRef.current = "keyboard";
|
|
167
|
+
|
|
168
|
+
setSelectionState((state) => {
|
|
169
|
+
const move = getOverviewGridSelectionMove(
|
|
170
|
+
state.selected,
|
|
171
|
+
total,
|
|
172
|
+
columns,
|
|
173
|
+
direction,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!move.didMove && direction === "ArrowUp") {
|
|
177
|
+
return {
|
|
178
|
+
selected: move.selected,
|
|
179
|
+
boundaryFeedback: {
|
|
180
|
+
direction: "up",
|
|
181
|
+
key: (state.boundaryFeedback?.key ?? 0) + 1,
|
|
182
|
+
selected: state.selected,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!move.didMove && direction === "ArrowDown") {
|
|
188
|
+
return {
|
|
189
|
+
selected: move.selected,
|
|
190
|
+
boundaryFeedback: {
|
|
191
|
+
direction: "down",
|
|
192
|
+
key: (state.boundaryFeedback?.key ?? 0) + 1,
|
|
193
|
+
selected: state.selected,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { selected: move.selected, boundaryFeedback: null };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
|
203
|
+
switch (e.key) {
|
|
204
|
+
case "ArrowRight":
|
|
205
|
+
case "ArrowLeft":
|
|
206
|
+
case "ArrowDown":
|
|
207
|
+
case "ArrowUp":
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
moveSelection(e.key);
|
|
210
|
+
break;
|
|
211
|
+
case "w":
|
|
212
|
+
case "a":
|
|
213
|
+
case "s":
|
|
214
|
+
case "d":
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
break;
|
|
217
|
+
case "Enter":
|
|
218
|
+
if (e.target !== e.currentTarget) return;
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
jumpTo(selected);
|
|
221
|
+
break;
|
|
222
|
+
case "o":
|
|
223
|
+
case "Escape":
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
onClose();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
className="fixed inset-0 z-[100] overflow-y-auto bg-background/50 text-foreground outline-none backdrop-blur-xl overscroll-contain"
|
|
233
|
+
tabIndex={-1}
|
|
234
|
+
ref={containerRef}
|
|
235
|
+
onKeyDown={handleKeyDown}
|
|
236
|
+
role="dialog"
|
|
237
|
+
aria-label="Slide overview"
|
|
238
|
+
data-honeydeck-scrollable="true"
|
|
239
|
+
>
|
|
240
|
+
<div className="sticky top-0 z-20 flex justify-between items-center bg-background/75 px-4 py-3 backdrop-blur-xl border-b border-border/50">
|
|
241
|
+
<span className="text-foreground/60 text-base font-sans">
|
|
242
|
+
{total} slide{total !== 1 ? "s" : ""}
|
|
243
|
+
{!isMobile && " — click or press Enter to jump"}
|
|
244
|
+
</span>
|
|
245
|
+
<div className="flex items-center gap-2">
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={onClose}
|
|
249
|
+
className="h-8 rounded-md border border-border bg-primary px-3 text-sm text-primary-foreground"
|
|
250
|
+
>
|
|
251
|
+
Close
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div
|
|
257
|
+
style={{
|
|
258
|
+
padding: isMobile ? MOBILE_PADDING : 24,
|
|
259
|
+
paddingTop: isMobile ? MOBILE_PADDING : 24,
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<div
|
|
263
|
+
ref={gridRef}
|
|
264
|
+
className="grid justify-center"
|
|
265
|
+
style={{
|
|
266
|
+
gap,
|
|
267
|
+
gridTemplateColumns: isMobile
|
|
268
|
+
? `repeat(${MOBILE_COLUMNS}, ${thumbW}px)`
|
|
269
|
+
: `repeat(auto-fill, ${DESKTOP_THUMB_W}px)`,
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{slideData.map((slide, i) => {
|
|
273
|
+
const isActive = i + 1 === currentSlide;
|
|
274
|
+
const isSelected = i === selected;
|
|
275
|
+
const showSelection = !isMobile && isSelected;
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div
|
|
279
|
+
key={
|
|
280
|
+
isSelected && boundaryFeedback?.selected === i
|
|
281
|
+
? `${slide.id}-${boundaryFeedback.key}`
|
|
282
|
+
: slide.id
|
|
283
|
+
}
|
|
284
|
+
ref={isSelected ? selectedRef : null}
|
|
285
|
+
style={{ scrollMarginBlock: SELECTION_SCROLL_MARGIN }}
|
|
286
|
+
data-slide-index={i}
|
|
287
|
+
data-boundary-feedback={
|
|
288
|
+
isSelected && boundaryFeedback?.selected === i
|
|
289
|
+
? boundaryFeedback.direction
|
|
290
|
+
: undefined
|
|
291
|
+
}
|
|
292
|
+
className={`honeydeck-overview-thumbnail block rounded-md overflow-hidden relative bg-background transition-all duration-100 ease-out outline-solid hover:outline-2 ${
|
|
293
|
+
showSelection
|
|
294
|
+
? "outline-4 outline-foreground shadow-lg scale-[1.01]"
|
|
295
|
+
: "outline-1 outline-foreground/40"
|
|
296
|
+
}`}
|
|
297
|
+
>
|
|
298
|
+
<SlideCanvas
|
|
299
|
+
slideIndex={i}
|
|
300
|
+
stepIndex={0}
|
|
301
|
+
scale={thumbScale}
|
|
302
|
+
showFutureSteps
|
|
303
|
+
style={{
|
|
304
|
+
width: canvasW,
|
|
305
|
+
height: canvasH,
|
|
306
|
+
pointerEvents: "none",
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
|
|
310
|
+
<div className="absolute bottom-1.5 right-2 bg-surface text-surface-foreground text-xs px-1.5 py-0.5 rounded-xs">
|
|
311
|
+
{i + 1}
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{isActive && (
|
|
315
|
+
<div className="absolute top-1.5 left-2 bg-accent text-accent-foreground text-2xs font-bold px-2 py-0.5 rounded-xs tracking-wider uppercase shadow">
|
|
316
|
+
Current
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
aria-current={isActive ? "true" : undefined}
|
|
322
|
+
aria-label={`Go to slide ${i + 1}`}
|
|
323
|
+
className="absolute inset-0 z-10"
|
|
324
|
+
onClick={() => jumpTo(i)}
|
|
325
|
+
onFocus={() => setSelectedFrom("direct", i)}
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
})}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function useOverviewMobile(): boolean {
|
|
337
|
+
const [isMobile, setIsMobile] = useState(() => {
|
|
338
|
+
if (typeof window === "undefined") return false;
|
|
339
|
+
return window.matchMedia("(pointer: coarse)").matches;
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
const query = window.matchMedia("(pointer: coarse)");
|
|
344
|
+
const update = () => setIsMobile(query.matches);
|
|
345
|
+
update();
|
|
346
|
+
query.addEventListener("change", update);
|
|
347
|
+
return () => query.removeEventListener("change", update);
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
350
|
+
return isMobile;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function easeOutCubic(t: number): number {
|
|
354
|
+
return 1 - (1 - t) ** 3;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getReducedMotionPreference(): boolean {
|
|
358
|
+
return (
|
|
359
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function getScrollTopForNearestElement(
|
|
364
|
+
container: HTMLElement,
|
|
365
|
+
element: HTMLElement,
|
|
366
|
+
margin: number,
|
|
367
|
+
): number {
|
|
368
|
+
const containerRect = container.getBoundingClientRect();
|
|
369
|
+
const elementRect = element.getBoundingClientRect();
|
|
370
|
+
const currentTop = container.scrollTop;
|
|
371
|
+
const viewportTop = currentTop;
|
|
372
|
+
const viewportBottom = currentTop + container.clientHeight;
|
|
373
|
+
const elementTop = currentTop + elementRect.top - containerRect.top;
|
|
374
|
+
const elementBottom = currentTop + elementRect.bottom - containerRect.top;
|
|
375
|
+
|
|
376
|
+
let targetTop = currentTop;
|
|
377
|
+
|
|
378
|
+
if (elementTop - margin < viewportTop) {
|
|
379
|
+
targetTop = elementTop - margin;
|
|
380
|
+
} else if (elementBottom + margin > viewportBottom) {
|
|
381
|
+
targetTop = elementBottom + margin - container.clientHeight;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
|
385
|
+
return Math.min(Math.max(0, targetTop), maxTop);
|
|
386
|
+
}
|