@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin.js +2 -0
- package/dist/build-DxTqmvsO.js +17 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +86 -0
- package/dist/config-CjzqjrEA.js +4280 -0
- package/dist/config-DIC-yVPp.d.ts +23 -0
- package/dist/design-cpzS8aud.js +35 -0
- package/dist/dev-BYuTeJbA.js +20 -0
- package/dist/format-BCeKbTOM.js +1605 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +467 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +3 -0
- package/dist/preview-DlQvnJPq.js +18 -0
- package/dist/sync-BPZ0m27m.js +139 -0
- package/dist/sync-EsYusbbL.js +3 -0
- package/dist/types-CHmFPIG_.d.ts +430 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +4 -0
- package/env.d.ts +59 -0
- package/package.json +103 -0
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +91 -0
- package/skills/create-theme/SKILL.md +250 -0
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +625 -0
- package/src/app/app.tsx +47 -0
- package/src/app/components/asset-view.tsx +966 -0
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +243 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +93 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +387 -0
- package/src/app/components/inspector/inspector-panel.tsx +1115 -0
- package/src/app/components/inspector/inspector-provider.tsx +1218 -0
- package/src/app/components/inspector/save-bar.tsx +48 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/notes-drawer.tsx +120 -0
- package/src/app/components/overview-grid.tsx +363 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +80 -0
- package/src/app/components/panel/save-card.tsx +142 -0
- package/src/app/components/pdf-progress-toast.tsx +32 -0
- package/src/app/components/player.tsx +466 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +315 -0
- package/src/app/components/present/help-overlay.tsx +57 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +39 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +46 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +66 -0
- package/src/app/components/present/use-touch-swipe.ts +66 -0
- package/src/app/components/shared-element.tsx +48 -0
- package/src/app/components/sidebar/folder-item.tsx +258 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
- package/src/app/components/sidebar/sidebar.tsx +284 -0
- package/src/app/components/slide-canvas.tsx +102 -0
- package/src/app/components/slide-transition-layer.tsx +844 -0
- package/src/app/components/style-panel/design-provider.tsx +148 -0
- package/src/app/components/style-panel/style-panel.tsx +349 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +59 -0
- package/src/app/components/themes/theme-detail.tsx +305 -0
- package/src/app/components/themes/themes-gallery.tsx +149 -0
- package/src/app/components/thumbnail-rail.tsx +805 -0
- package/src/app/components/ui/badge.tsx +45 -0
- package/src/app/components/ui/button.tsx +99 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/components/ui/dialog.tsx +157 -0
- package/src/app/components/ui/dropdown-menu.tsx +245 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/popover.tsx +75 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +196 -0
- package/src/app/components/ui/separator.tsx +28 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +58 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/assets.ts +242 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/export-html.ts +326 -0
- package/src/app/lib/export-pdf.ts +298 -0
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +85 -0
- package/src/app/lib/inspector/use-comments.ts +74 -0
- package/src/app/lib/inspector/use-editor.ts +73 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/print-ready.test.ts +32 -0
- package/src/app/lib/print-ready.ts +51 -0
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +37 -0
- package/src/app/lib/slides.ts +26 -0
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/transition.ts +30 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-click-page-navigation.ts +60 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/lib/use-wheel-page-navigation.ts +99 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +213 -0
- package/src/app/routes/home.tsx +807 -0
- package/src/app/routes/presenter.tsx +418 -0
- package/src/app/routes/slide.tsx +1108 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +429 -0
- package/src/app/virtual.d.ts +51 -0
- package/src/locale/en.ts +416 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +422 -0
- package/src/locale/types.ts +443 -0
- package/src/locale/zh-cn.ts +414 -0
- 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
|
+
}
|