@open-aippt/core 1.13.2

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,315 @@
1
+ import {
2
+ ChevronLeft,
3
+ ChevronRight,
4
+ Crosshair,
5
+ Grid2x2,
6
+ Keyboard,
7
+ LogOut,
8
+ Maximize,
9
+ Minimize,
10
+ MonitorSpeaker,
11
+ Square,
12
+ Sun,
13
+ } from 'lucide-react';
14
+ import { createContext, useContext, useEffect, useState } from 'react';
15
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
16
+ import { useLocale } from '@/lib/use-locale';
17
+ import { cn } from '@/lib/utils';
18
+
19
+ const TooltipContainerCtx = createContext<HTMLElement | null>(null);
20
+
21
+ type Props = {
22
+ index: number;
23
+ total: number;
24
+ visible: boolean;
25
+ startedAt: number;
26
+ blackout: 'black' | 'white' | null;
27
+ laser: boolean;
28
+ allowExit: boolean;
29
+ windowed: boolean;
30
+ onPrev: () => void;
31
+ onNext: () => void;
32
+ onMobileInteraction: () => void;
33
+ onOverview: () => void;
34
+ onBlackout: (mode: 'black' | 'white') => void;
35
+ onLaser: () => void;
36
+ onPresenter: () => void;
37
+ onToggleFullscreen: () => void;
38
+ onHelp: () => void;
39
+ onExit: () => void;
40
+ /**
41
+ * Where to portal tooltips. Required because the Player runs fullscreen
42
+ * — the default `document.body` portal is outside the fullscreen element
43
+ * and therefore invisible. Pass the player root.
44
+ */
45
+ tooltipContainer?: HTMLElement | null;
46
+ };
47
+
48
+ export function PresentControlBar({
49
+ index,
50
+ total,
51
+ visible,
52
+ startedAt,
53
+ blackout,
54
+ laser,
55
+ allowExit,
56
+ windowed,
57
+ onPrev,
58
+ onNext,
59
+ onMobileInteraction,
60
+ onOverview,
61
+ onBlackout,
62
+ onLaser,
63
+ onPresenter,
64
+ onToggleFullscreen,
65
+ onHelp,
66
+ onExit,
67
+ tooltipContainer,
68
+ }: Props) {
69
+ const t = useLocale();
70
+ const fullscreenAria = windowed ? t.present.enterFullscreenAria : t.present.exitFullscreenAria;
71
+ const handleMobileAction = (action: () => void) => {
72
+ action();
73
+ onMobileInteraction();
74
+ };
75
+
76
+ return (
77
+ <div
78
+ data-state={visible ? 'visible' : 'hidden'}
79
+ className={cn(
80
+ 'pointer-events-none absolute inset-x-0 bottom-0 z-40 flex justify-center px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] md:px-4 md:pb-4',
81
+ 'will-change-[translate,scale,opacity,filter]',
82
+ 'motion-safe:transition-[translate,scale,opacity,filter]',
83
+ 'motion-safe:duration-[420ms] motion-safe:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]',
84
+ visible
85
+ ? 'translate-y-0 scale-100 opacity-100 blur-none'
86
+ : 'translate-y-8 scale-90 opacity-0 blur-md',
87
+ )}
88
+ >
89
+ <TooltipProvider delayDuration={300}>
90
+ <TooltipContainerCtx.Provider value={tooltipContainer ?? null}>
91
+ <div
92
+ className={cn(
93
+ 'hidden h-11 items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 text-white/85 shadow-[0_8px_30px_-8px_oklch(0_0_0/0.6)] backdrop-blur-md md:flex',
94
+ visible ? 'pointer-events-auto' : 'pointer-events-none',
95
+ )}
96
+ >
97
+ <BarButton label={t.present.prevSlideAria} onClick={onPrev} disabled={index === 0}>
98
+ <ChevronLeft className="size-4" />
99
+ </BarButton>
100
+ <BarButton
101
+ label={t.present.nextSlideAria}
102
+ onClick={onNext}
103
+ disabled={index >= total - 1}
104
+ >
105
+ <ChevronRight className="size-4" />
106
+ </BarButton>
107
+
108
+ <Divider />
109
+
110
+ <span className="px-2 font-mono text-[11.5px] tracking-[0.08em] tabular-nums uppercase select-none text-white/85">
111
+ <span className="text-white">{(index + 1).toString().padStart(2, '0')}</span>
112
+ <span className="text-white/35"> / </span>
113
+ <span>{total.toString().padStart(2, '0')}</span>
114
+ </span>
115
+
116
+ <Divider />
117
+
118
+ <ElapsedClock startedAt={startedAt} />
119
+
120
+ <Divider />
121
+
122
+ <BarButton label={t.present.overviewAria} onClick={onOverview}>
123
+ <Grid2x2 className="size-4" />
124
+ </BarButton>
125
+ <BarButton
126
+ label={t.present.blackoutAria}
127
+ onClick={() => onBlackout('black')}
128
+ active={blackout === 'black'}
129
+ >
130
+ <Square className="size-4 fill-current" />
131
+ </BarButton>
132
+ <BarButton
133
+ label={t.present.whiteoutAria}
134
+ onClick={() => onBlackout('white')}
135
+ active={blackout === 'white'}
136
+ >
137
+ <Sun className="size-4" />
138
+ </BarButton>
139
+ <BarButton label={t.present.laserAria} onClick={onLaser} active={laser}>
140
+ <Crosshair className="size-4" />
141
+ </BarButton>
142
+ <BarButton label={t.present.presenterAria} onClick={onPresenter}>
143
+ <MonitorSpeaker className="size-4" />
144
+ </BarButton>
145
+ <BarButton label={fullscreenAria} onClick={onToggleFullscreen}>
146
+ {windowed ? <Maximize className="size-4" /> : <Minimize className="size-4" />}
147
+ </BarButton>
148
+ <BarButton label={t.present.helpAria} onClick={onHelp}>
149
+ <Keyboard className="size-4" />
150
+ </BarButton>
151
+
152
+ {allowExit && (
153
+ <>
154
+ <Divider />
155
+ <BarButton label={t.present.exitAria} onClick={onExit}>
156
+ <LogOut className="size-4" />
157
+ </BarButton>
158
+ </>
159
+ )}
160
+ </div>
161
+
162
+ <div
163
+ className={cn(
164
+ 'flex w-fit max-w-[calc(100vw-1.5rem)] md:hidden',
165
+ visible ? 'pointer-events-auto' : 'pointer-events-none',
166
+ )}
167
+ >
168
+ <div className="flex h-10 w-fit items-center gap-0.5 rounded-full border border-white/10 bg-black/60 px-1 text-white/85 shadow-[0_8px_30px_-8px_oklch(0_0_0/0.65)] backdrop-blur-md">
169
+ <MobileBarButton
170
+ label={t.present.prevSlideAria}
171
+ onClick={() => handleMobileAction(onPrev)}
172
+ disabled={index === 0}
173
+ >
174
+ <ChevronLeft className="size-4" />
175
+ </MobileBarButton>
176
+ <span className="min-w-[3.5rem] px-1 text-center font-mono text-[11.5px] tabular-nums text-white/80 select-none">
177
+ <span className="text-white">{index + 1}</span>
178
+ <span className="px-1 text-white/35">/</span>
179
+ <span>{total}</span>
180
+ </span>
181
+ <MobileBarButton
182
+ label={t.present.nextSlideAria}
183
+ onClick={() => handleMobileAction(onNext)}
184
+ disabled={index >= total - 1}
185
+ >
186
+ <ChevronRight className="size-4" />
187
+ </MobileBarButton>
188
+ <MobileDivider />
189
+ <MobileBarButton
190
+ label={t.present.overviewAria}
191
+ onClick={() => handleMobileAction(onOverview)}
192
+ >
193
+ <Grid2x2 className="size-4" />
194
+ </MobileBarButton>
195
+ {allowExit && (
196
+ <MobileBarButton
197
+ label={t.present.exitAria}
198
+ onClick={() => handleMobileAction(onExit)}
199
+ >
200
+ <LogOut className="size-4" />
201
+ </MobileBarButton>
202
+ )}
203
+ </div>
204
+ </div>
205
+ </TooltipContainerCtx.Provider>
206
+ </TooltipProvider>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ function MobileBarButton({
212
+ children,
213
+ label,
214
+ onClick,
215
+ disabled,
216
+ }: {
217
+ children: React.ReactNode;
218
+ label: string;
219
+ onClick: () => void;
220
+ disabled?: boolean;
221
+ }) {
222
+ return (
223
+ <button
224
+ type="button"
225
+ aria-label={label}
226
+ disabled={disabled}
227
+ onClick={(event) => {
228
+ event.stopPropagation();
229
+ onClick();
230
+ }}
231
+ className={cn(
232
+ 'inline-flex size-8 shrink-0 touch-manipulation items-center justify-center rounded-full transition-colors',
233
+ 'text-white/85 hover:bg-white/12 focus-visible:bg-white/12 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/35',
234
+ 'disabled:pointer-events-none disabled:opacity-30',
235
+ )}
236
+ >
237
+ {children}
238
+ </button>
239
+ );
240
+ }
241
+
242
+ function MobileDivider() {
243
+ return <span aria-hidden className="mx-0.5 h-3.5 w-px shrink-0 bg-white/15" />;
244
+ }
245
+
246
+ function BarButton({
247
+ children,
248
+ label,
249
+ onClick,
250
+ disabled,
251
+ active,
252
+ }: {
253
+ children: React.ReactNode;
254
+ label: string;
255
+ onClick: () => void;
256
+ disabled?: boolean;
257
+ active?: boolean;
258
+ }) {
259
+ const container = useContext(TooltipContainerCtx);
260
+ return (
261
+ <Tooltip>
262
+ <TooltipTrigger asChild>
263
+ <button
264
+ type="button"
265
+ aria-label={label}
266
+ disabled={disabled}
267
+ onClick={(event) => {
268
+ event.stopPropagation();
269
+ onClick();
270
+ }}
271
+ className={cn(
272
+ 'inline-flex size-8 items-center justify-center rounded-full transition-colors',
273
+ 'hover:bg-white/12 focus-visible:bg-white/12 focus-visible:outline-none',
274
+ 'disabled:pointer-events-none disabled:opacity-30',
275
+ active && 'bg-[var(--brand,#ef4444)]/85 text-white hover:bg-[var(--brand,#ef4444)]',
276
+ )}
277
+ >
278
+ {children}
279
+ </button>
280
+ </TooltipTrigger>
281
+ <TooltipContent
282
+ container={container ?? undefined}
283
+ side="top"
284
+ sideOffset={6}
285
+ className="bg-black/85 text-white"
286
+ >
287
+ {label}
288
+ </TooltipContent>
289
+ </Tooltip>
290
+ );
291
+ }
292
+
293
+ function Divider() {
294
+ return <span aria-hidden className="mx-1 h-4 w-px bg-white/15" />;
295
+ }
296
+
297
+ function ElapsedClock({ startedAt }: { startedAt: number }) {
298
+ const [now, setNow] = useState(() => Date.now());
299
+ const t = useLocale();
300
+ useEffect(() => {
301
+ const id = setInterval(() => setNow(Date.now()), 1000);
302
+ return () => clearInterval(id);
303
+ }, []);
304
+ const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
305
+ const m = Math.floor(elapsed / 60);
306
+ const s = elapsed % 60;
307
+ return (
308
+ <time
309
+ title={t.present.elapsedTime}
310
+ className="px-2 font-mono text-[11.5px] tracking-[0.08em] tabular-nums uppercase select-none text-white/70"
311
+ >
312
+ {m.toString().padStart(2, '0')}:{s.toString().padStart(2, '0')}
313
+ </time>
314
+ );
315
+ }
@@ -0,0 +1,57 @@
1
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
2
+ import { useLocale } from '@/lib/use-locale';
3
+
4
+ type Props = {
5
+ open: boolean;
6
+ onOpenChange: (open: boolean) => void;
7
+ /** Portal target — pass the player root so the dialog renders inside
8
+ * the fullscreen subtree (otherwise it paints invisibly under it). */
9
+ container?: HTMLElement | null;
10
+ };
11
+
12
+ export function PresentHelpOverlay({ open, onOpenChange, container }: Props) {
13
+ const t = useLocale();
14
+ const shortcuts: Array<{ keys: string[]; label: string }> = [
15
+ { keys: ['→', '↓', 'Space', 'PgDn'], label: t.present.shortcutNext },
16
+ { keys: ['←', '↑', 'PgUp'], label: t.present.shortcutPrev },
17
+ { keys: ['Home', 'End'], label: t.present.shortcutFirstLast },
18
+ { keys: ['1–9', 'Enter'], label: t.present.shortcutJump },
19
+ { keys: ['O'], label: t.present.shortcutOverview },
20
+ { keys: ['B'], label: t.present.shortcutBlack },
21
+ { keys: ['W'], label: t.present.shortcutWhite },
22
+ { keys: ['L'], label: t.present.shortcutLaser },
23
+ { keys: ['P'], label: t.present.shortcutPresenter },
24
+ { keys: ['?', 'H'], label: t.present.shortcutToggleHelp },
25
+ { keys: ['Esc'], label: t.present.shortcutCloseExit },
26
+ ];
27
+ return (
28
+ <Dialog open={open} onOpenChange={onOpenChange}>
29
+ <DialogContent container={container ?? undefined} className="max-w-lg sm:max-w-lg">
30
+ <DialogHeader>
31
+ <span className="eyebrow">{t.present.helpEyebrow}</span>
32
+ <DialogTitle>{t.present.helpTitle}</DialogTitle>
33
+ </DialogHeader>
34
+ <div className="grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
35
+ {shortcuts.map((row) => (
36
+ <div
37
+ key={row.label}
38
+ className="flex items-center justify-between gap-3 border-b border-hairline py-1.5 last:border-0"
39
+ >
40
+ <span className="text-[12.5px] text-foreground/85">{row.label}</span>
41
+ <span className="flex shrink-0 items-center gap-1">
42
+ {row.keys.map((k) => (
43
+ <kbd
44
+ key={k}
45
+ className="rounded-[4px] border border-border bg-muted px-1.5 py-0.5 font-mono text-[10.5px] tabular-nums"
46
+ >
47
+ {k}
48
+ </kbd>
49
+ ))}
50
+ </span>
51
+ </div>
52
+ ))}
53
+ </div>
54
+ </DialogContent>
55
+ </Dialog>
56
+ );
57
+ }
@@ -0,0 +1,74 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ const FLUSH_DELAY_MS = 1200;
4
+
5
+ type Props = {
6
+ pageCount: number;
7
+ onJump: (index: number) => void;
8
+ };
9
+
10
+ /**
11
+ * Listens for digit keypresses anywhere on the document and shows a
12
+ * transient "→ 7" badge. Pressing Enter (or letting it idle) flushes the
13
+ * buffer and jumps to the slide. Designed to be invisible until the user
14
+ * starts typing — never steals focus, never shows an input element.
15
+ */
16
+ export function PresentJumpInput({ pageCount, onJump }: Props) {
17
+ const [buffer, setBuffer] = useState('');
18
+ const flushRef = useRef<ReturnType<typeof setTimeout> | null>(null);
19
+
20
+ useEffect(() => {
21
+ const flush = () => {
22
+ setBuffer((current) => {
23
+ if (!current) return current;
24
+ const n = Number.parseInt(current, 10);
25
+ if (Number.isFinite(n) && n >= 1) {
26
+ onJump(Math.min(pageCount, n) - 1);
27
+ }
28
+ return '';
29
+ });
30
+ };
31
+
32
+ const onKey = (e: KeyboardEvent) => {
33
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
34
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
35
+
36
+ if (/^[0-9]$/.test(e.key)) {
37
+ e.preventDefault();
38
+ setBuffer((b) => (b + e.key).slice(0, 4));
39
+ if (flushRef.current) clearTimeout(flushRef.current);
40
+ flushRef.current = setTimeout(flush, FLUSH_DELAY_MS);
41
+ return;
42
+ }
43
+ if (e.key === 'Enter') {
44
+ if (flushRef.current) clearTimeout(flushRef.current);
45
+ flush();
46
+ return;
47
+ }
48
+ if (e.key === 'Backspace') {
49
+ setBuffer((b) => b.slice(0, -1));
50
+ return;
51
+ }
52
+ if (e.key === 'Escape' || e.key === ' ') {
53
+ setBuffer('');
54
+ }
55
+ };
56
+
57
+ window.addEventListener('keydown', onKey);
58
+ return () => {
59
+ window.removeEventListener('keydown', onKey);
60
+ if (flushRef.current) clearTimeout(flushRef.current);
61
+ };
62
+ }, [pageCount, onJump]);
63
+
64
+ if (!buffer) return null;
65
+ return (
66
+ <div
67
+ aria-live="polite"
68
+ className="pointer-events-none absolute top-1/2 left-1/2 z-40 -translate-x-1/2 -translate-y-1/2 select-none rounded-[10px] bg-black/70 px-6 py-4 font-mono text-[44px] font-medium tracking-[0.05em] text-white tabular-nums shadow-[0_8px_40px_-8px_oklch(0_0_0/0.6)] backdrop-blur-md"
69
+ >
70
+ <span className="text-white/60">→ </span>
71
+ {buffer}
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ type Pos = { x: number; y: number } | null;
4
+
5
+ /**
6
+ * Soft red dot that follows the cursor when the laser tool is active.
7
+ * Hides the system cursor on the player root via a `cursor-none` class
8
+ * applied by the parent.
9
+ */
10
+ export function PresentLaserPointer({ enabled }: { enabled: boolean }) {
11
+ const [pos, setPos] = useState<Pos>(null);
12
+
13
+ useEffect(() => {
14
+ if (!enabled) {
15
+ setPos(null);
16
+ return;
17
+ }
18
+ const onMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
19
+ window.addEventListener('mousemove', onMove, { passive: true });
20
+ return () => window.removeEventListener('mousemove', onMove);
21
+ }, [enabled]);
22
+
23
+ if (!enabled || !pos) return null;
24
+ return (
25
+ <div
26
+ aria-hidden
27
+ className="pointer-events-none fixed top-0 left-0 z-[60]"
28
+ style={{
29
+ width: 18,
30
+ height: 18,
31
+ transform: `translate3d(${pos.x - 9}px, ${pos.y - 9}px, 0)`,
32
+ willChange: 'transform',
33
+ borderRadius: '50%',
34
+ background: 'radial-gradient(circle, oklch(0.66 0.24 28 / 0.95) 30%, transparent 70%)',
35
+ boxShadow: '0 0 18px 4px oklch(0.66 0.24 28 / 0.55)',
36
+ }}
37
+ />
38
+ );
39
+ }
@@ -0,0 +1,26 @@
1
+ import { cn } from '@/lib/utils';
2
+
3
+ type Props = {
4
+ index: number;
5
+ total: number;
6
+ visible: boolean;
7
+ };
8
+
9
+ export function PresentProgressBar({ index, total, visible }: Props) {
10
+ const pct = total > 0 ? (index + 1) / total : 0;
11
+ return (
12
+ <div
13
+ aria-hidden
14
+ className={cn(
15
+ 'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] overflow-hidden bg-white/8',
16
+ 'motion-safe:transition-opacity motion-safe:duration-200',
17
+ visible ? 'opacity-100' : 'opacity-0',
18
+ )}
19
+ >
20
+ <div
21
+ className="h-full w-full origin-left bg-[var(--brand,#ef4444)] transition-transform duration-200 ease-out"
22
+ style={{ transform: `scaleX(${pct})` }}
23
+ />
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Reports whether the user has been idle (no pointer / touch input) for at
5
+ * least `delayMs`. Resets on any pointer-related event. The hook starts in
6
+ * the non-idle state so freshly-mounted UI is visible while the user
7
+ * orients themselves.
8
+ *
9
+ * Keyboard input is intentionally excluded — during a talk the presenter
10
+ * drives slides with arrow keys, and we want the cursor to stay hidden
11
+ * while they do.
12
+ *
13
+ * Pass `enabled = false` to short-circuit (useful when the player is
14
+ * paused on an overlay and we don't want to hide chrome behind it).
15
+ */
16
+ export function useIdle(delayMs: number, enabled = true) {
17
+ const [idle, setIdle] = useState(false);
18
+
19
+ useEffect(() => {
20
+ if (!enabled) {
21
+ setIdle(false);
22
+ return;
23
+ }
24
+ let timer: ReturnType<typeof setTimeout> | null = null;
25
+ const reset = () => {
26
+ setIdle(false);
27
+ if (timer) clearTimeout(timer);
28
+ timer = setTimeout(() => setIdle(true), delayMs);
29
+ };
30
+ reset();
31
+ const opts = { passive: true } as const;
32
+ window.addEventListener('mousemove', reset, opts);
33
+ window.addEventListener('mousedown', reset, opts);
34
+ window.addEventListener('touchstart', reset, opts);
35
+ window.addEventListener('wheel', reset, opts);
36
+ return () => {
37
+ if (timer) clearTimeout(timer);
38
+ window.removeEventListener('mousemove', reset);
39
+ window.removeEventListener('mousedown', reset);
40
+ window.removeEventListener('touchstart', reset);
41
+ window.removeEventListener('wheel', reset);
42
+ };
43
+ }, [delayMs, enabled]);
44
+
45
+ return idle;
46
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Returns true while the mouse pointer sits within `thresholdPx` of the
5
+ * viewport's bottom edge. Pure pointer-position tracking — keyboard input
6
+ * does not affect the result, so arrow-key navigation won't reveal the
7
+ * control chrome.
8
+ *
9
+ * Pass `enabled = false` to short-circuit (e.g. when an overlay owns
10
+ * visibility) and reset to false.
11
+ */
12
+ export function usePointerNearBottom(thresholdPx: number, enabled = true) {
13
+ const [near, setNear] = useState(false);
14
+
15
+ useEffect(() => {
16
+ if (!enabled) {
17
+ setNear(false);
18
+ return;
19
+ }
20
+ const update = (clientY: number) => {
21
+ setNear(clientY >= window.innerHeight - thresholdPx);
22
+ };
23
+ const onMove = (e: MouseEvent) => update(e.clientY);
24
+ const onLeave = () => setNear(false);
25
+ window.addEventListener('mousemove', onMove, { passive: true });
26
+ document.addEventListener('mouseleave', onLeave);
27
+ return () => {
28
+ window.removeEventListener('mousemove', onMove);
29
+ document.removeEventListener('mouseleave', onLeave);
30
+ };
31
+ }, [thresholdPx, enabled]);
32
+
33
+ return near;
34
+ }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+
3
+ export type PresenterState = {
4
+ index: number;
5
+ pageCount: number;
6
+ blackout: 'black' | 'white' | null;
7
+ startedAt: number; // epoch ms when present mode began
8
+ stepIndex: number;
9
+ stepCount: number;
10
+ };
11
+
12
+ export type PresenterCommand =
13
+ | { type: 'state'; state: PresenterState }
14
+ | { type: 'goto'; index: number }
15
+ | { type: 'next' }
16
+ | { type: 'prev' }
17
+ | { type: 'request-state' }
18
+ | { type: 'restart-timer' }
19
+ | { type: 'toggle-blackout'; mode: 'black' | 'white' };
20
+
21
+ type Handler = (msg: PresenterCommand) => void;
22
+
23
+ const SUPPORTED = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined';
24
+
25
+ // Channel ownership lives in the effect (not useMemo) so StrictMode's
26
+ // double-invoke produces a fresh channel on remount rather than leaving a
27
+ // closed one behind that throws on the next send().
28
+ export function usePresenterChannel(slideId: string, onMessage?: Handler) {
29
+ const onMessageRef = useRef(onMessage);
30
+ onMessageRef.current = onMessage;
31
+
32
+ const channelRef = useRef<BroadcastChannel | null>(null);
33
+ const [available, setAvailable] = useState(false);
34
+
35
+ useEffect(() => {
36
+ if (!SUPPORTED) return;
37
+ const channel = new BroadcastChannel(`open-aippt:presenter:${slideId}`);
38
+ channelRef.current = channel;
39
+ setAvailable(true);
40
+ const handler = (e: MessageEvent<PresenterCommand>) => {
41
+ onMessageRef.current?.(e.data);
42
+ };
43
+ channel.addEventListener('message', handler);
44
+ return () => {
45
+ channel.removeEventListener('message', handler);
46
+ channel.close();
47
+ if (channelRef.current === channel) channelRef.current = null;
48
+ setAvailable(false);
49
+ };
50
+ }, [slideId]);
51
+
52
+ return useMemo(
53
+ () => ({
54
+ send(msg: PresenterCommand) {
55
+ try {
56
+ channelRef.current?.postMessage(msg);
57
+ } catch {
58
+ // Channel may have been closed between the availability check
59
+ // and the send (e.g. StrictMode unmount mid-flush). Treat as no-op.
60
+ }
61
+ },
62
+ available,
63
+ }),
64
+ [available],
65
+ );
66
+ }