@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.
Files changed (144) hide show
  1. package/AGENTS.md +25 -0
  2. package/DEVELOPMENT.md +522 -0
  3. package/LICENSE +21 -0
  4. package/Readme.md +49 -0
  5. package/SPEC.md +88 -0
  6. package/docs/components.md +63 -0
  7. package/docs/configuration.md +91 -0
  8. package/docs/getting-started.md +116 -0
  9. package/docs/kit-authoring.md +207 -0
  10. package/docs/kits.md +387 -0
  11. package/docs/local-development.md +95 -0
  12. package/docs/mermaid.md +198 -0
  13. package/docs/mobile.md +108 -0
  14. package/docs/navigation.md +93 -0
  15. package/docs/next-steps.md +377 -0
  16. package/docs/pdf-export.md +91 -0
  17. package/docs/presenter-mode.md +104 -0
  18. package/docs/slides.md +130 -0
  19. package/docs/slidev-migration.md +42 -0
  20. package/docs/steps-and-reveals.md +171 -0
  21. package/package.json +134 -0
  22. package/skills/SPEC.md +21 -0
  23. package/skills/honeydeck/SKILL.md +65 -0
  24. package/skills/presentation-writing/SKILL.md +75 -0
  25. package/skills/slidev-migration/SKILL.md +153 -0
  26. package/src/SPEC.md +89 -0
  27. package/src/assets.d.ts +30 -0
  28. package/src/cli/SPEC.md +230 -0
  29. package/src/cli/args.ts +3 -0
  30. package/src/cli/banner.ts +9 -0
  31. package/src/cli/bin.js +5 -0
  32. package/src/cli/build.ts +229 -0
  33. package/src/cli/deck-path.ts +32 -0
  34. package/src/cli/dev.ts +263 -0
  35. package/src/cli/index.ts +126 -0
  36. package/src/cli/init.ts +369 -0
  37. package/src/cli/pdf.ts +923 -0
  38. package/src/cli/skill.ts +75 -0
  39. package/src/cli/templates/SPEC.md +70 -0
  40. package/src/cli/templates/deck-mdx.ts +15 -0
  41. package/src/cli/templates/package-json.ts +36 -0
  42. package/src/cli/templates/sparkle-button.ts +15 -0
  43. package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
  44. package/src/cli/templates/starter/deck.mdx +153 -0
  45. package/src/cli/templates/starter/styles.css +14 -0
  46. package/src/cli/templates/styles-css.ts +14 -0
  47. package/src/defaults.ts +1 -0
  48. package/src/layouts/ColorModeImage.tsx +55 -0
  49. package/src/layouts/SPEC.md +393 -0
  50. package/src/layouts/SlideFrame.tsx +48 -0
  51. package/src/layouts/bee/Blank.tsx +12 -0
  52. package/src/layouts/bee/Cover.tsx +70 -0
  53. package/src/layouts/bee/Default.tsx +42 -0
  54. package/src/layouts/bee/Image/Image.tsx +151 -0
  55. package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
  56. package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
  57. package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
  58. package/src/layouts/bee/Image/placeholder.webp +0 -0
  59. package/src/layouts/bee/ImageLeft.tsx +27 -0
  60. package/src/layouts/bee/ImageRight.tsx +27 -0
  61. package/src/layouts/bee/ImageSide.tsx +107 -0
  62. package/src/layouts/bee/Section.tsx +40 -0
  63. package/src/layouts/bee/TwoCol.tsx +108 -0
  64. package/src/layouts/bee/index.ts +40 -0
  65. package/src/layouts/clean/Blank.tsx +12 -0
  66. package/src/layouts/clean/Cover.tsx +58 -0
  67. package/src/layouts/clean/Default.tsx +33 -0
  68. package/src/layouts/clean/Image/Image.tsx +103 -0
  69. package/src/layouts/clean/ImageLeft.tsx +27 -0
  70. package/src/layouts/clean/ImageRight.tsx +27 -0
  71. package/src/layouts/clean/ImageSide.tsx +113 -0
  72. package/src/layouts/clean/Section.tsx +35 -0
  73. package/src/layouts/clean/TwoCol.tsx +63 -0
  74. package/src/layouts/clean/index.ts +40 -0
  75. package/src/layouts/index.ts +60 -0
  76. package/src/layouts/placeholders.ts +9 -0
  77. package/src/layouts/utils.ts +13 -0
  78. package/src/remark/SPEC.md +49 -0
  79. package/src/remark/h1-extract.ts +124 -0
  80. package/src/remark/index.ts +4 -0
  81. package/src/remark/shiki-code-blocks.ts +325 -0
  82. package/src/remark/step-numbering.ts +412 -0
  83. package/src/runtime/Deck.tsx +533 -0
  84. package/src/runtime/SPEC.md +256 -0
  85. package/src/runtime/SlideCanvas.tsx +95 -0
  86. package/src/runtime/TimelineContext.tsx +122 -0
  87. package/src/runtime/app-shell/index.html +31 -0
  88. package/src/runtime/app-shell/main.tsx +42 -0
  89. package/src/runtime/aspectRatio.ts +34 -0
  90. package/src/runtime/colorMode.ts +23 -0
  91. package/src/runtime/components/BrowserFrame.tsx +233 -0
  92. package/src/runtime/components/Button.tsx +57 -0
  93. package/src/runtime/components/CodeBlock.tsx +210 -0
  94. package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
  95. package/src/runtime/components/ErrorBoundary.tsx +125 -0
  96. package/src/runtime/components/Keyboard.tsx +87 -0
  97. package/src/runtime/components/ListStyle.tsx +203 -0
  98. package/src/runtime/components/NavBar.tsx +223 -0
  99. package/src/runtime/components/NavBarButton.tsx +47 -0
  100. package/src/runtime/components/NavBarDivider.tsx +3 -0
  101. package/src/runtime/components/Notes.tsx +171 -0
  102. package/src/runtime/components/Reveal.tsx +82 -0
  103. package/src/runtime/components/RevealGroup.tsx +193 -0
  104. package/src/runtime/components/SPEC.md +263 -0
  105. package/src/runtime/components/SlideNumberBadge.tsx +11 -0
  106. package/src/runtime/components/TimelineSteps.tsx +115 -0
  107. package/src/runtime/components/index.ts +55 -0
  108. package/src/runtime/index.ts +42 -0
  109. package/src/runtime/inputOwnership.ts +68 -0
  110. package/src/runtime/keyboardTarget.ts +7 -0
  111. package/src/runtime/lastSlideRoute.ts +56 -0
  112. package/src/runtime/navigation.ts +211 -0
  113. package/src/runtime/router.ts +157 -0
  114. package/src/runtime/slideData.ts +137 -0
  115. package/src/runtime/sync.ts +267 -0
  116. package/src/runtime/types.ts +182 -0
  117. package/src/runtime/useKeyboardNav.ts +138 -0
  118. package/src/runtime/useSwipeNav.ts +257 -0
  119. package/src/runtime/views/DocsView.tsx +74 -0
  120. package/src/runtime/views/OverviewView.tsx +386 -0
  121. package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
  122. package/src/runtime/views/PresenterView.tsx +340 -0
  123. package/src/runtime/views/SPEC.md +152 -0
  124. package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
  125. package/src/runtime/views/docs/DocsHeader.tsx +101 -0
  126. package/src/runtime/views/docs/Intro.tsx +20 -0
  127. package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
  128. package/src/runtime/views/docs/ThemeTab.tsx +110 -0
  129. package/src/runtime/views/index.ts +7 -0
  130. package/src/runtime/views/overviewGrid.ts +106 -0
  131. package/src/runtime/views/presenterPreview.ts +27 -0
  132. package/src/runtime/virtual-modules.d.ts +98 -0
  133. package/src/theme/SPEC.md +179 -0
  134. package/src/theme/base.css +623 -0
  135. package/src/theme/bee.css +35 -0
  136. package/src/theme/clean.css +38 -0
  137. package/src/vite-plugin/SPEC.md +114 -0
  138. package/src/vite-plugin/component-doc-crawler.ts +350 -0
  139. package/src/vite-plugin/deck-loader.ts +148 -0
  140. package/src/vite-plugin/index.ts +373 -0
  141. package/src/vite-plugin/layout-demo-crawler.ts +802 -0
  142. package/src/vite-plugin/splitter.ts +353 -0
  143. package/src/vite-plugin/token-manifest.ts +163 -0
  144. package/src/vite-plugin/virtual-modules.ts +587 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * BroadcastChannel sync for Honeydeck presenter ↔ audience coordination.
3
+ *
4
+ * The presenter window is the **controller** — it sends `navigate` messages
5
+ * whenever its route changes. Audience windows listen and apply them.
6
+ *
7
+ * Late-opening audience tabs send a `sync-request` so the presenter can reply
8
+ * with a `sync-response` containing the current route immediately.
9
+ *
10
+ * ### Message types
11
+ * - `navigate` — presenter changed slide/step
12
+ * - `sync-request` — audience asks for the current presenter route
13
+ * - `sync-response` — presenter replies with the current route
14
+ * - `presenter-connected` — a presenter window opened
15
+ * - `presenter-disconnected`— a presenter window closed
16
+ *
17
+ * ### Constraints
18
+ * - Same-origin, same browser profile only (BroadcastChannel limitation).
19
+ * - If BroadcastChannel is unavailable, the hook is a no-op.
20
+ *
21
+ * ### Usage
22
+ * ```tsx
23
+ * // In audience Deck:
24
+ * useSync({ isPresenter: false });
25
+ *
26
+ * // In PresenterView:
27
+ * useSync({ isPresenter: true, currentSlide: slide, currentStep: step });
28
+ * ```
29
+ */
30
+
31
+ import { useEffect, useRef, useState } from "react";
32
+ import { navigate, parseHash, type Route } from "./router.ts";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export type SyncNavigateMessage = {
39
+ type: "navigate";
40
+ slide: number;
41
+ step: number;
42
+ };
43
+
44
+ export type SyncRequestMessage = {
45
+ type: "sync-request";
46
+ };
47
+
48
+ export type SyncResponseMessage = {
49
+ type: "sync-response";
50
+ slide: number;
51
+ step: number;
52
+ };
53
+
54
+ export type SyncPresenceMessage =
55
+ | { type: "presenter-connected" }
56
+ | { type: "presenter-disconnected" };
57
+
58
+ export type SyncMessage =
59
+ | SyncNavigateMessage
60
+ | SyncRequestMessage
61
+ | SyncResponseMessage
62
+ | SyncPresenceMessage;
63
+
64
+ export type UseSyncOptions = {
65
+ /** Whether this hook should connect to BroadcastChannel. */
66
+ enabled?: boolean;
67
+ /** True when this window is the presenter (controller). */
68
+ isPresenter: boolean;
69
+ /** Current 1-based slide number (required when `isPresenter: true`). */
70
+ currentSlide?: number;
71
+ /** Current 0-based step index (required when `isPresenter: true`). */
72
+ currentStep?: number;
73
+ };
74
+
75
+ type PresenterRoute = {
76
+ slide: number;
77
+ step: number;
78
+ };
79
+
80
+ const CHANNEL_NAME = "honeydeck";
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Pure helpers
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export function createSyncRequestMessage(): SyncRequestMessage {
87
+ return { type: "sync-request" };
88
+ }
89
+
90
+ export function createSyncResponseMessage(
91
+ route: PresenterRoute,
92
+ ): SyncResponseMessage {
93
+ return {
94
+ type: "sync-response",
95
+ slide: route.slide,
96
+ step: route.step,
97
+ };
98
+ }
99
+
100
+ export function resolveAudienceRouteFromSyncMessage(
101
+ currentRoute: Route,
102
+ message: SyncNavigateMessage | SyncResponseMessage,
103
+ ): Route | null {
104
+ if (currentRoute.view === "kit") return null;
105
+
106
+ return {
107
+ view: currentRoute.view === "overview" ? "overview" : "slide",
108
+ slide: message.slide,
109
+ step: message.step,
110
+ };
111
+ }
112
+
113
+ function isSyncMessage(value: unknown): value is SyncMessage {
114
+ if (typeof value !== "object" || value === null) return false;
115
+ if (!("type" in value)) return false;
116
+
117
+ const type = (value as { type?: unknown }).type;
118
+ if (type === "sync-request") return true;
119
+ if (type === "presenter-connected") return true;
120
+ if (type === "presenter-disconnected") return true;
121
+
122
+ if (type !== "navigate" && type !== "sync-response") return false;
123
+
124
+ const slide = (value as { slide?: unknown }).slide;
125
+ const step = (value as { step?: unknown }).step;
126
+ return (
127
+ typeof slide === "number" &&
128
+ Number.isFinite(slide) &&
129
+ typeof step === "number" &&
130
+ Number.isFinite(step)
131
+ );
132
+ }
133
+
134
+ function isRouteSyncMessage(
135
+ message: SyncMessage,
136
+ ): message is SyncNavigateMessage | SyncResponseMessage {
137
+ return message.type === "navigate" || message.type === "sync-response";
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Hook
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Bidirectional sync hook.
146
+ *
147
+ * @returns `{ presenterConnected }` — true when an audience window has
148
+ * detected that a presenter window is open on the same channel.
149
+ */
150
+ export function useSync({
151
+ enabled = true,
152
+ isPresenter,
153
+ currentSlide,
154
+ currentStep,
155
+ }: UseSyncOptions): { presenterConnected: boolean } {
156
+ const [presenterConnected, setPresenterConnected] = useState(false);
157
+ const channelRef = useRef<BroadcastChannel | null>(null);
158
+ const presenterRouteRef = useRef<PresenterRoute>({
159
+ slide: currentSlide ?? 1,
160
+ step: currentStep ?? 0,
161
+ });
162
+
163
+ useEffect(() => {
164
+ if (!isPresenter) return;
165
+ if (currentSlide === undefined || currentStep === undefined) return;
166
+
167
+ presenterRouteRef.current = {
168
+ slide: currentSlide,
169
+ step: currentStep,
170
+ };
171
+ }, [currentSlide, currentStep, isPresenter]);
172
+
173
+ // ── Channel lifecycle ───────────────────────────────────────────────────
174
+
175
+ useEffect(() => {
176
+ if (!enabled) return;
177
+
178
+ // BroadcastChannel may not exist in every environment (e.g. node tests).
179
+ if (typeof BroadcastChannel === "undefined") return;
180
+
181
+ const channel = new BroadcastChannel(CHANNEL_NAME);
182
+ channelRef.current = channel;
183
+
184
+ // Announce presence when presenter opens.
185
+ if (isPresenter) {
186
+ channel.postMessage({
187
+ type: "presenter-connected",
188
+ } satisfies SyncMessage);
189
+ }
190
+
191
+ channel.addEventListener("message", (e: MessageEvent<unknown>) => {
192
+ if (!isSyncMessage(e.data)) return;
193
+
194
+ const msg = e.data;
195
+
196
+ if (isPresenter) {
197
+ // Presenter is controller. It only answers sync requests.
198
+ if (msg.type === "sync-request") {
199
+ channel.postMessage(
200
+ createSyncResponseMessage(
201
+ presenterRouteRef.current,
202
+ ) satisfies SyncMessage,
203
+ );
204
+ }
205
+ return;
206
+ }
207
+
208
+ if (msg.type === "presenter-disconnected") {
209
+ setPresenterConnected(false);
210
+ return;
211
+ }
212
+
213
+ if (msg.type === "presenter-connected") {
214
+ setPresenterConnected(true);
215
+ return;
216
+ }
217
+
218
+ if (isRouteSyncMessage(msg)) {
219
+ setPresenterConnected(true);
220
+ const currentRoute = parseHash(location.hash);
221
+ const nextRoute = resolveAudienceRouteFromSyncMessage(
222
+ currentRoute,
223
+ msg,
224
+ );
225
+ if (nextRoute) {
226
+ navigate(nextRoute);
227
+ }
228
+ }
229
+ });
230
+
231
+ if (!isPresenter) {
232
+ channel.postMessage(createSyncRequestMessage() satisfies SyncMessage);
233
+ }
234
+
235
+ return () => {
236
+ if (isPresenter) {
237
+ channel.postMessage({
238
+ type: "presenter-disconnected",
239
+ } satisfies SyncMessage);
240
+ }
241
+ channel.close();
242
+ channelRef.current = null;
243
+ };
244
+ }, [enabled, isPresenter]);
245
+
246
+ // ── Presenter: broadcast navigation changes ─────────────────────────────
247
+
248
+ useEffect(() => {
249
+ if (!isPresenter) return;
250
+ if (currentSlide === undefined || currentStep === undefined) return;
251
+
252
+ presenterRouteRef.current = {
253
+ slide: currentSlide,
254
+ step: currentStep,
255
+ };
256
+
257
+ if (!enabled) return;
258
+
259
+ channelRef.current?.postMessage({
260
+ type: "navigate",
261
+ slide: currentSlide,
262
+ step: currentStep,
263
+ } satisfies SyncMessage);
264
+ }, [enabled, isPresenter, currentSlide, currentStep]);
265
+
266
+ return { presenterConnected };
267
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Core type definitions for Honeydeck layouts and reference documentation.
3
+ *
4
+ * These types are exported from `@honeydeck/honeydeck/types` for use by kit and layout authors.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import type { LayoutProps, LayoutMap, LayoutDemo } from '@honeydeck/honeydeck/types'
9
+ *
10
+ * type CoverFrontmatter = { author?: string }
11
+ *
12
+ * export default function CoverLayout({
13
+ * title, children, frontmatter
14
+ * }: LayoutProps<CoverFrontmatter>) { ... }
15
+ *
16
+ * export const demo: LayoutDemo<CoverFrontmatter> = {
17
+ * mdx: '---\nlayout: Cover\nauthor: Hendrik\n---\n\n# My Talk',
18
+ * }
19
+ * ```
20
+ */
21
+
22
+ import type { ComponentType, ReactNode } from "react";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // LayoutProps
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Props passed to every layout component.
30
+ *
31
+ * The generic parameter `F` types the layout-specific frontmatter fields
32
+ * (e.g. `author`, `date` for a Cover layout). This enables a future
33
+ * language server to provide autocomplete for layout-specific frontmatter.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * type CoverFrontmatter = { author?: string }
38
+ *
39
+ * export default function CoverLayout({
40
+ * title, children, frontmatter,
41
+ * }: LayoutProps<CoverFrontmatter>) {
42
+ * return (
43
+ * <div>
44
+ * <h1>{title}</h1>
45
+ * {children}
46
+ * </div>
47
+ * )
48
+ * }
49
+ * ```
50
+ */
51
+ export type LayoutProps<
52
+ F extends Record<string, unknown> = Record<string, unknown>,
53
+ > = {
54
+ /**
55
+ * Text content of the first `h1` in the slide, extracted at build time.
56
+ * `null` when the slide has no h1.
57
+ */
58
+ title: ReactNode | null;
59
+
60
+ /**
61
+ * Slide body — everything after the h1 has been removed.
62
+ * This is what most layouts render as the main content area.
63
+ */
64
+ children: ReactNode;
65
+
66
+ /**
67
+ * Full, unmodified slide content including the original h1.
68
+ * Useful for layouts that want to render the slide in one piece.
69
+ * Phase 3: same as `children` (improvement planned for Phase 8).
70
+ */
71
+ rawChildren: ReactNode;
72
+
73
+ /**
74
+ * Parsed slide-level frontmatter fields (YAML block at top of the slide).
75
+ */
76
+ frontmatter: F;
77
+ };
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // LayoutMap
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * A map of layout names to layout components.
85
+ *
86
+ * Used as the value of the `layouts:` frontmatter property.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * // layouts/index.ts
91
+ * import companyLayouts from '@company/honeydeck-kit-brand/layouts'
92
+ * import { MyCustomCover } from './Cover'
93
+ *
94
+ * export default {
95
+ * ...companyLayouts,
96
+ * Cover: MyCustomCover,
97
+ * } satisfies LayoutMap
98
+ * ```
99
+ */
100
+ // biome-ignore lint/suspicious/noExplicitAny: Layout maps intentionally hold heterogeneous layout components with layout-specific frontmatter.
101
+ export type LayoutMap = Record<string, ComponentType<LayoutProps<any>>>;
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // LayoutDemo
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Optional demo data exported from a layout component for the docs reference page.
109
+ *
110
+ * When exported as `demo` from a layout module, Honeydeck renders a live preview
111
+ * of the layout on `/#/layouts` using this data.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * export const demo: LayoutDemo<CoverFrontmatter> = {
116
+ * mdx: '---\nlayout: Cover\nauthor: Hendrik\n---\n\n# Welcome to My Talk',
117
+ * }
118
+ * ```
119
+ */
120
+ export type LayoutDemo<
121
+ F extends Record<string, unknown> = Record<string, unknown>,
122
+ > = {
123
+ /** MDX source used for both the live preview and copyable snippet. */
124
+ mdx: string;
125
+ } & (F extends Record<string, unknown> ? unknown : never);
126
+
127
+ export type CompiledLayoutDemo = LayoutDemo & {
128
+ /** Compiled MDX component for the demo slide body. */
129
+ Component: ComponentType;
130
+ /** Number of timeline steps discovered in the demo MDX. */
131
+ stepCount: number;
132
+ /** Plain-text content of the first h1 in the demo MDX. */
133
+ title: string;
134
+ /** Parsed YAML frontmatter from the demo MDX. */
135
+ frontmatter: Record<string, unknown>;
136
+ /** Layout selected by demo frontmatter, or an empty string for the card layout. */
137
+ layoutName: string;
138
+ };
139
+
140
+ export type LayoutPropDoc = {
141
+ /** Frontmatter property name as written in MDX. */
142
+ name: string;
143
+ /** TypeScript-style type label. */
144
+ type: string;
145
+ /** Whether the frontmatter property is required. */
146
+ required: boolean;
147
+ /** Human-readable description from the layout's type comments. */
148
+ description: string;
149
+ };
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // ComponentDoc
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Documentation for one public component prop, extracted from the component's
157
+ * exported props type.
158
+ */
159
+ export type ComponentPropDoc = {
160
+ /** Prop name */
161
+ name: string;
162
+ /** TypeScript type text */
163
+ type: string;
164
+ /** Whether the prop is required */
165
+ required: boolean;
166
+ /** JSDoc description from the prop field */
167
+ description: string;
168
+ /** Default value inferred from the component parameter destructuring */
169
+ defaultValue?: string;
170
+ };
171
+
172
+ /**
173
+ * Generated docs entry for one public built-in component.
174
+ */
175
+ export type ComponentDoc = {
176
+ /** Compiled Markdown/MDX from the component's exported JSDoc comment */
177
+ Component: ComponentType;
178
+ /** Raw Markdown source, useful for tooling and tests */
179
+ markdown: string;
180
+ /** Props extracted from the component's exported props type */
181
+ props: ComponentPropDoc[];
182
+ };
@@ -0,0 +1,138 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { isEditableKeyboardTarget } from "./keyboardTarget.ts";
3
+ import {
4
+ nextSlide,
5
+ nextStep,
6
+ openPresenter,
7
+ previousSlide,
8
+ previousStep,
9
+ toggleOverview,
10
+ } from "./navigation.ts";
11
+ import type { Route } from "./router.ts";
12
+ import { parseHash } from "./router.ts";
13
+
14
+ export type UseKeyboardNavOptions = {
15
+ /** Whether this hook should register keyboard navigation. */
16
+ enabled?: boolean;
17
+ /** Total number of slides in the deck. */
18
+ slideCount: number;
19
+ /** Returns the total step count for a given 0-based slide index. */
20
+ getStepCount: (slideIndex: number) => number;
21
+ /** Called when the user presses `o` or `Escape` while in overview. */
22
+ onToggleOverview?: () => void;
23
+ /** Whether overview mode is currently active. */
24
+ isOverview?: boolean;
25
+ };
26
+
27
+ export function useKeyboardNav({
28
+ enabled = true,
29
+ slideCount,
30
+ getStepCount,
31
+ onToggleOverview,
32
+ isOverview,
33
+ }: UseKeyboardNavOptions): void {
34
+ const slideCountRef = useRef(slideCount);
35
+ slideCountRef.current = slideCount;
36
+
37
+ const getStepCountRef = useRef(getStepCount);
38
+ getStepCountRef.current = getStepCount;
39
+
40
+ const onToggleOverviewRef = useRef(onToggleOverview);
41
+ onToggleOverviewRef.current = onToggleOverview;
42
+
43
+ const isOverviewRef = useRef(isOverview ?? false);
44
+ isOverviewRef.current = isOverview ?? false;
45
+
46
+ useEffect(() => {
47
+ if (!enabled) return;
48
+
49
+ const handler = (e: KeyboardEvent) => {
50
+ if (isEditableKeyboardTarget(e.target)) return;
51
+
52
+ const route: Route = parseHash(location.hash);
53
+ if (route.view === "kit") return;
54
+
55
+ const total = slideCountRef.current;
56
+ const options = {
57
+ slideCount: total,
58
+ getStepCount: getStepCountRef.current,
59
+ };
60
+ const inOverview = isOverviewRef.current;
61
+
62
+ switch (e.key) {
63
+ case "ArrowRight":
64
+ case "d": {
65
+ e.preventDefault();
66
+ if (!inOverview) nextStep(route, options);
67
+ break;
68
+ }
69
+
70
+ case "ArrowLeft":
71
+ case "a": {
72
+ e.preventDefault();
73
+ if (!inOverview) previousStep(route, options);
74
+ break;
75
+ }
76
+
77
+ case "ArrowDown":
78
+ case "s": {
79
+ e.preventDefault();
80
+ if (!inOverview) nextSlide(route, { slideCount: total });
81
+ break;
82
+ }
83
+
84
+ case "ArrowUp":
85
+ case "w": {
86
+ e.preventDefault();
87
+ if (!inOverview) previousSlide(route);
88
+ break;
89
+ }
90
+
91
+ case "o": {
92
+ e.preventDefault();
93
+ if (onToggleOverviewRef.current) {
94
+ onToggleOverviewRef.current();
95
+ } else {
96
+ toggleOverview(route);
97
+ }
98
+ break;
99
+ }
100
+
101
+ case "p": {
102
+ if (route.view !== "presenter") {
103
+ e.preventDefault();
104
+ openPresenter(route);
105
+ }
106
+ break;
107
+ }
108
+
109
+ case "f": {
110
+ e.preventDefault();
111
+ if (document.fullscreenElement) {
112
+ document.exitFullscreen();
113
+ } else {
114
+ document.documentElement.requestFullscreen().catch(() => {
115
+ // Fullscreen may be blocked (e.g. in iframes).
116
+ });
117
+ }
118
+ break;
119
+ }
120
+
121
+ case "Escape": {
122
+ if (isOverviewRef.current) {
123
+ e.preventDefault();
124
+ if (onToggleOverviewRef.current) {
125
+ onToggleOverviewRef.current();
126
+ } else {
127
+ toggleOverview(route);
128
+ }
129
+ }
130
+ break;
131
+ }
132
+ }
133
+ };
134
+
135
+ window.addEventListener("keydown", handler);
136
+ return () => window.removeEventListener("keydown", handler);
137
+ }, [enabled]);
138
+ }