@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,805 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ type DragStartEvent,
6
+ KeyboardSensor,
7
+ PointerSensor,
8
+ useSensor,
9
+ useSensors,
10
+ } from '@dnd-kit/core';
11
+ import {
12
+ SortableContext,
13
+ sortableKeyboardCoordinates,
14
+ useSortable,
15
+ verticalListSortingStrategy,
16
+ } from '@dnd-kit/sortable';
17
+ import { CSS } from '@dnd-kit/utilities';
18
+ import { Copy, Grid2x2, ListOrdered, type LucideIcon, Sparkles, Trash2 } from 'lucide-react';
19
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
20
+ import {
21
+ ContextMenu,
22
+ ContextMenuContent,
23
+ ContextMenuItem,
24
+ ContextMenuSeparator,
25
+ ContextMenuTrigger,
26
+ } from '@/components/ui/context-menu';
27
+ import { ScrollArea } from '@/components/ui/scroll-area';
28
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
29
+ import { format, useLocale } from '@/lib/use-locale';
30
+ import { cn } from '@/lib/utils';
31
+ import type { DesignSystem } from '../lib/design';
32
+ import { SlidePageProvider } from '../lib/page-context';
33
+ import type { Page } from '../lib/sdk';
34
+ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
35
+ import type { SlideTransition } from '../lib/transition';
36
+ import { SlideCanvas } from './slide-canvas';
37
+
38
+ type Orientation = 'vertical' | 'horizontal';
39
+
40
+ export type ThumbnailActions = {
41
+ onDuplicate: (index: number) => void;
42
+ onDelete: (index: number) => void;
43
+ };
44
+
45
+ type Props = {
46
+ pages: Page[];
47
+ design?: DesignSystem;
48
+ current: number;
49
+ onSelect: (index: number) => void;
50
+ onReorder?: (from: number, to: number) => void;
51
+ actions?: ThumbnailActions;
52
+ orientation?: Orientation;
53
+ /** Vertical-only: total rail width in px. Thumbnails scale to fit. */
54
+ width?: number;
55
+ /** Deck-level transition default; used to flag pages that inherit a transition. */
56
+ moduleTransition?: SlideTransition;
57
+ /** When provided, the vertical rail header renders a button that opens the overview grid. */
58
+ onOverview?: () => void;
59
+ };
60
+
61
+ const DEFAULT_VERTICAL_THUMB_WIDTH = 184;
62
+ const VERTICAL_RAIL_CHROME = 80;
63
+ const MIN_VERTICAL_THUMB_WIDTH = 120;
64
+ const HORIZONTAL_THUMB_HEIGHT = 64;
65
+ const HORIZONTAL_THUMB_GAP = 8;
66
+ const HORIZONTAL_RAIL_PADDING_X = 12;
67
+ const HORIZONTAL_RAIL_PADDING_Y = 10;
68
+ const HORIZONTAL_LABEL_HEIGHT = 12;
69
+ const HORIZONTAL_LABEL_GAP = 6;
70
+ const VERTICAL_THUMB_PADDING_Y = 12;
71
+ const VERTICAL_THUMB_GAP = 8;
72
+ const VIRTUAL_OVERSCAN = 4;
73
+
74
+ export function ThumbnailRail({
75
+ pages,
76
+ design,
77
+ current,
78
+ onSelect,
79
+ onReorder,
80
+ actions,
81
+ orientation = 'vertical',
82
+ width,
83
+ moduleTransition,
84
+ onOverview,
85
+ }: Props) {
86
+ const activeRef = useRef<HTMLButtonElement | null>(null);
87
+ const t = useLocale();
88
+
89
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `current` triggers re-scroll on selection change
90
+ useEffect(() => {
91
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
92
+
93
+ activeRef.current?.scrollIntoView({
94
+ block: 'nearest',
95
+ inline: 'nearest',
96
+ behavior: reduceMotion ? 'auto' : 'smooth',
97
+ });
98
+ }, [current]);
99
+
100
+ const thumbWidth =
101
+ width != null
102
+ ? Math.max(MIN_VERTICAL_THUMB_WIDTH, width - VERTICAL_RAIL_CHROME)
103
+ : DEFAULT_VERTICAL_THUMB_WIDTH;
104
+ const scale = thumbWidth / CANVAS_WIDTH;
105
+ const height = CANVAS_HEIGHT * scale;
106
+ const rowHeight = height + VERTICAL_THUMB_PADDING_Y + VERTICAL_THUMB_GAP;
107
+
108
+ const renderThumb = useCallback(
109
+ (PageComp: Page, i: number) => {
110
+ const active = i === current;
111
+ const inner = (
112
+ <ThumbContents
113
+ index={i}
114
+ total={pages.length}
115
+ active={active}
116
+ page={PageComp}
117
+ design={design}
118
+ scale={scale}
119
+ thumbWidth={thumbWidth}
120
+ height={height}
121
+ moduleTransition={moduleTransition}
122
+ />
123
+ );
124
+
125
+ const node = onReorder ? (
126
+ <SortableThumb
127
+ index={i}
128
+ active={active}
129
+ activeRef={active ? activeRef : undefined}
130
+ onSelect={() => onSelect(i)}
131
+ ariaLabel={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
132
+ >
133
+ {inner}
134
+ </SortableThumb>
135
+ ) : (
136
+ <button
137
+ type="button"
138
+ ref={active ? activeRef : undefined}
139
+ onClick={() => onSelect(i)}
140
+ aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
141
+ aria-current={active ? 'true' : undefined}
142
+ className={thumbButtonClass(active)}
143
+ >
144
+ {inner}
145
+ </button>
146
+ );
147
+
148
+ if (!actions) {
149
+ return <Fragment key={i}>{node}</Fragment>;
150
+ }
151
+ return (
152
+ <ThumbContextMenu
153
+ key={i}
154
+ index={i}
155
+ actions={actions}
156
+ pageCount={pages.length}
157
+ ariaLabel={format(t.thumbnailRail.pageActionsAria, { n: i + 1 })}
158
+ >
159
+ {node}
160
+ </ThumbContextMenu>
161
+ );
162
+ },
163
+ [
164
+ actions,
165
+ current,
166
+ design,
167
+ height,
168
+ moduleTransition,
169
+ onReorder,
170
+ onSelect,
171
+ pages.length,
172
+ scale,
173
+ thumbWidth,
174
+ t.thumbnailRail.goToPageAria,
175
+ t.thumbnailRail.pageActionsAria,
176
+ ],
177
+ );
178
+
179
+ if (orientation === 'horizontal') {
180
+ const scale = HORIZONTAL_THUMB_HEIGHT / CANVAS_HEIGHT;
181
+ const horizontalWidth = CANVAS_WIDTH * scale;
182
+ return (
183
+ <div className="bg-sidebar">
184
+ <div className="overflow-x-auto overflow-y-hidden">
185
+ <HorizontalVirtualThumbList
186
+ pages={pages}
187
+ design={design}
188
+ current={current}
189
+ actions={actions}
190
+ activeRef={activeRef}
191
+ onSelect={onSelect}
192
+ scale={scale}
193
+ thumbWidth={horizontalWidth}
194
+ />
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ const list = (
201
+ <aside className="flex flex-col gap-2 px-3 pb-3">
202
+ <div className="-mx-3 sticky top-0 z-10 bg-sidebar px-4 pt-3 pb-1">
203
+ <div className="flex items-center justify-between gap-2">
204
+ <span className="eyebrow">{t.thumbnailRail.pages}</span>
205
+ <div className="flex items-center gap-1.5">
206
+ <span className="folio">{pages.length.toString().padStart(2, '0')}</span>
207
+ {onOverview && (
208
+ <Tooltip>
209
+ <TooltipTrigger asChild>
210
+ <button
211
+ type="button"
212
+ onClick={onOverview}
213
+ aria-label={t.thumbnailRail.overviewAria}
214
+ className={cn(
215
+ 'flex size-5 items-center justify-center rounded-[3px] text-muted-foreground/70 outline-none',
216
+ 'motion-safe:transition-colors hover:bg-muted hover:text-foreground',
217
+ 'focus-visible:ring-1 focus-visible:ring-brand',
218
+ )}
219
+ >
220
+ <Grid2x2 className="size-3.5" strokeWidth={1.75} />
221
+ </button>
222
+ </TooltipTrigger>
223
+ <TooltipContent side="bottom" sideOffset={6}>
224
+ {t.thumbnailRail.overviewAria}
225
+ </TooltipContent>
226
+ </Tooltip>
227
+ )}
228
+ </div>
229
+ </div>
230
+ </div>
231
+ <VirtualThumbList
232
+ pages={pages}
233
+ current={current}
234
+ rowHeight={rowHeight}
235
+ activeRef={activeRef}
236
+ renderThumb={renderThumb}
237
+ />
238
+ </aside>
239
+ );
240
+
241
+ if (!onReorder) {
242
+ return (
243
+ <TooltipProvider delayDuration={200}>
244
+ <ScrollArea className="h-full border-r border-hairline bg-sidebar [&_[data-slot=scroll-area-scrollbar]]:z-20">
245
+ {list}
246
+ </ScrollArea>
247
+ </TooltipProvider>
248
+ );
249
+ }
250
+
251
+ return (
252
+ <TooltipProvider delayDuration={200}>
253
+ <ScrollArea className="h-full border-r border-hairline bg-sidebar [&_[data-slot=scroll-area-scrollbar]]:z-20">
254
+ <SortableRail pages={pages} onReorder={onReorder} onSelect={onSelect}>
255
+ {list}
256
+ </SortableRail>
257
+ </ScrollArea>
258
+ </TooltipProvider>
259
+ );
260
+ }
261
+
262
+ function thumbButtonClass(active: boolean): string {
263
+ return cn(
264
+ 'group/thumb flex w-full items-start gap-2.5 rounded-[6px] p-1.5 text-left motion-safe:transition-colors',
265
+ 'hover:bg-muted/60',
266
+ active && 'bg-muted',
267
+ );
268
+ }
269
+
270
+ function HorizontalVirtualThumbList({
271
+ pages,
272
+ design,
273
+ current,
274
+ actions,
275
+ activeRef,
276
+ onSelect,
277
+ scale,
278
+ thumbWidth,
279
+ }: {
280
+ pages: Page[];
281
+ design?: DesignSystem;
282
+ current: number;
283
+ actions?: ThumbnailActions;
284
+ activeRef: React.MutableRefObject<HTMLButtonElement | null>;
285
+ onSelect: (index: number) => void;
286
+ scale: number;
287
+ thumbWidth: number;
288
+ }) {
289
+ const rootRef = useRef<HTMLDivElement | null>(null);
290
+ const viewportRef = useRef<HTMLElement | null>(null);
291
+ const t = useLocale();
292
+ const itemWidth = thumbWidth + HORIZONTAL_THUMB_GAP;
293
+ const listHeight =
294
+ HORIZONTAL_RAIL_PADDING_Y * 2 +
295
+ HORIZONTAL_LABEL_HEIGHT +
296
+ HORIZONTAL_LABEL_GAP +
297
+ HORIZONTAL_THUMB_HEIGHT;
298
+ const [range, setRange] = useState(() => getInitialVisibleRange(current, pages.length));
299
+
300
+ const updateRange = useCallback(() => {
301
+ const viewport = viewportRef.current;
302
+ if (!viewport) return;
303
+ const scrollLeft = Math.max(0, viewport.scrollLeft - HORIZONTAL_RAIL_PADDING_X);
304
+ setRange(getVisibleRange(scrollLeft, pages.length, viewport.clientWidth, itemWidth));
305
+ }, [itemWidth, pages.length]);
306
+
307
+ useLayoutEffect(() => {
308
+ const root = rootRef.current;
309
+ const viewport = root?.parentElement;
310
+ if (!viewport) return;
311
+ viewportRef.current = viewport;
312
+
313
+ let frame = 0;
314
+ const scheduleUpdate = () => {
315
+ cancelAnimationFrame(frame);
316
+ frame = requestAnimationFrame(updateRange);
317
+ };
318
+ const resizeObserver = new ResizeObserver(scheduleUpdate);
319
+
320
+ viewport.addEventListener('scroll', scheduleUpdate, { passive: true });
321
+ resizeObserver.observe(viewport);
322
+ scheduleUpdate();
323
+
324
+ return () => {
325
+ cancelAnimationFrame(frame);
326
+ viewport.removeEventListener('scroll', scheduleUpdate);
327
+ resizeObserver.disconnect();
328
+ if (viewportRef.current === viewport) viewportRef.current = null;
329
+ };
330
+ }, [updateRange]);
331
+
332
+ useLayoutEffect(() => {
333
+ if (pages.length <= 0) return;
334
+ const viewport = viewportRef.current ?? rootRef.current?.parentElement;
335
+ if (!viewport) return;
336
+ viewportRef.current = viewport;
337
+
338
+ const clampedCurrent = Math.min(Math.max(current, 0), pages.length - 1);
339
+ const left = HORIZONTAL_RAIL_PADDING_X + clampedCurrent * itemWidth;
340
+ const right = left + thumbWidth;
341
+ const viewportLeft = viewport.scrollLeft;
342
+ const viewportRight = viewportLeft + viewport.clientWidth;
343
+
344
+ if (left < viewportLeft) {
345
+ viewport.scrollLeft = left;
346
+ } else if (right > viewportRight) {
347
+ viewport.scrollLeft = right - viewport.clientWidth;
348
+ }
349
+
350
+ const scrollLeft = Math.max(0, viewport.scrollLeft - HORIZONTAL_RAIL_PADDING_X);
351
+ setRange(getVisibleRange(scrollLeft, pages.length, viewport.clientWidth, itemWidth));
352
+ }, [current, itemWidth, pages.length, thumbWidth]);
353
+
354
+ const visibleRange = clampVisibleRange(range, current, pages.length);
355
+ const visible = [];
356
+ for (let i = visibleRange.start; i < visibleRange.end; i++) {
357
+ const PageComp = pages[i];
358
+ const active = i === current;
359
+ const button = (
360
+ <button
361
+ type="button"
362
+ ref={active ? activeRef : undefined}
363
+ onClick={() => onSelect(i)}
364
+ aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
365
+ aria-current={active ? 'true' : undefined}
366
+ className={cn('group/thumb relative flex shrink-0 flex-col items-center gap-1.5')}
367
+ >
368
+ <span
369
+ className={cn(
370
+ 'font-mono text-[9.5px] font-medium tracking-[0.06em] tabular-nums uppercase',
371
+ active ? 'text-brand' : 'text-muted-foreground/70',
372
+ )}
373
+ >
374
+ {(i + 1).toString().padStart(2, '0')}
375
+ </span>
376
+ <div
377
+ className={cn(
378
+ 'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-[border-color,box-shadow]',
379
+ active
380
+ ? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
381
+ : 'border-hairline group-hover/thumb:border-foreground/25',
382
+ )}
383
+ style={{ width: thumbWidth, height: HORIZONTAL_THUMB_HEIGHT }}
384
+ >
385
+ <SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
386
+ <SlidePageProvider index={i} total={pages.length}>
387
+ <PageComp />
388
+ </SlidePageProvider>
389
+ </SlideCanvas>
390
+ </div>
391
+ </button>
392
+ );
393
+
394
+ visible.push(
395
+ <div
396
+ key={i}
397
+ className="absolute top-2.5"
398
+ style={{ left: HORIZONTAL_RAIL_PADDING_X + i * itemWidth, width: thumbWidth }}
399
+ >
400
+ {actions ? (
401
+ <ThumbContextMenu
402
+ index={i}
403
+ actions={actions}
404
+ pageCount={pages.length}
405
+ ariaLabel={format(t.thumbnailRail.pageActionsAria, { n: i + 1 })}
406
+ >
407
+ {button}
408
+ </ThumbContextMenu>
409
+ ) : (
410
+ button
411
+ )}
412
+ </div>,
413
+ );
414
+ }
415
+
416
+ return (
417
+ <div
418
+ ref={rootRef}
419
+ className="relative"
420
+ style={{
421
+ width: HORIZONTAL_RAIL_PADDING_X * 2 + pages.length * itemWidth - HORIZONTAL_THUMB_GAP,
422
+ height: listHeight,
423
+ }}
424
+ >
425
+ {visible}
426
+ </div>
427
+ );
428
+ }
429
+
430
+ function VirtualThumbList({
431
+ pages,
432
+ current,
433
+ rowHeight,
434
+ activeRef,
435
+ renderThumb,
436
+ }: {
437
+ pages: Page[];
438
+ current: number;
439
+ rowHeight: number;
440
+ activeRef: React.MutableRefObject<HTMLButtonElement | null>;
441
+ renderThumb: (page: Page, index: number) => React.ReactNode;
442
+ }) {
443
+ const rootRef = useRef<HTMLDivElement | null>(null);
444
+ const viewportRef = useRef<HTMLElement | null>(null);
445
+ const [range, setRange] = useState(() => getInitialVisibleRange(current, pages.length));
446
+
447
+ const updateRange = useCallback(() => {
448
+ const viewport = viewportRef.current;
449
+ const root = rootRef.current;
450
+ if (!viewport || !root) return;
451
+ const scrollTop = Math.max(0, viewport.scrollTop - root.offsetTop);
452
+ setRange(getVisibleRange(scrollTop, pages.length, viewport.clientHeight, rowHeight));
453
+ }, [pages.length, rowHeight]);
454
+
455
+ useEffect(() => {
456
+ const root = rootRef.current;
457
+ if (!root) return;
458
+ const viewport = root.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null;
459
+ if (!viewport) return;
460
+ viewportRef.current = viewport;
461
+
462
+ let frame = 0;
463
+ const scheduleUpdate = () => {
464
+ cancelAnimationFrame(frame);
465
+ frame = requestAnimationFrame(updateRange);
466
+ };
467
+ const resizeObserver = new ResizeObserver(scheduleUpdate);
468
+
469
+ viewport.addEventListener('scroll', scheduleUpdate, { passive: true });
470
+ resizeObserver.observe(viewport);
471
+ scheduleUpdate();
472
+
473
+ return () => {
474
+ cancelAnimationFrame(frame);
475
+ viewport.removeEventListener('scroll', scheduleUpdate);
476
+ resizeObserver.disconnect();
477
+ if (viewportRef.current === viewport) viewportRef.current = null;
478
+ };
479
+ }, [updateRange]);
480
+
481
+ useEffect(() => {
482
+ const viewport = viewportRef.current;
483
+ const root = rootRef.current;
484
+ if (!viewport || !root) return;
485
+
486
+ const top = root.offsetTop + current * rowHeight;
487
+ const bottom = top + rowHeight;
488
+ const viewportTop = viewport.scrollTop;
489
+ const viewportBottom = viewportTop + viewport.clientHeight;
490
+
491
+ if (top < viewportTop) {
492
+ viewport.scrollTo({ top, behavior: scrollBehavior() });
493
+ } else if (bottom > viewportBottom) {
494
+ viewport.scrollTo({ top: bottom - viewport.clientHeight, behavior: scrollBehavior() });
495
+ } else {
496
+ activeRef.current?.scrollIntoView({
497
+ block: 'nearest',
498
+ inline: 'nearest',
499
+ behavior: scrollBehavior(),
500
+ });
501
+ }
502
+ }, [activeRef, current, rowHeight]);
503
+
504
+ const visibleRange = clampVisibleRange(range, current, pages.length);
505
+ const visible = [];
506
+ for (let i = visibleRange.start; i < visibleRange.end; i++) {
507
+ visible.push(
508
+ <div
509
+ key={i}
510
+ className="absolute right-0 left-0"
511
+ style={{ top: i * rowHeight, height: rowHeight }}
512
+ >
513
+ {renderThumb(pages[i], i)}
514
+ </div>,
515
+ );
516
+ }
517
+
518
+ return (
519
+ <div ref={rootRef} className="relative" style={{ height: pages.length * rowHeight }}>
520
+ {visible}
521
+ </div>
522
+ );
523
+ }
524
+
525
+ type VisibleRange = { start: number; end: number };
526
+
527
+ function clampVisibleRange(range: VisibleRange, current: number, count: number): VisibleRange {
528
+ if (count <= 0) return { start: 0, end: 0 };
529
+ if (range.start >= 0 && range.start < count && range.start < range.end) {
530
+ return { start: range.start, end: Math.min(range.end, count) };
531
+ }
532
+ const clampedCurrent = Math.min(Math.max(current, 0), count - 1);
533
+ return getInitialVisibleRange(clampedCurrent, count);
534
+ }
535
+
536
+ function getVisibleRange(
537
+ scrollTop: number,
538
+ count: number,
539
+ viewportHeight: number,
540
+ rowHeight: number,
541
+ ): VisibleRange {
542
+ if (count <= 0) return { start: 0, end: 0 };
543
+ const visibleRows = Math.max(1, Math.ceil(viewportHeight / rowHeight));
544
+ const firstVisible = Math.min(count - 1, Math.max(0, Math.floor(scrollTop / rowHeight)));
545
+ const start = Math.max(0, firstVisible - VIRTUAL_OVERSCAN);
546
+ const end = Math.min(count, firstVisible + visibleRows + VIRTUAL_OVERSCAN + 1);
547
+ return { start, end };
548
+ }
549
+
550
+ function getInitialVisibleRange(current: number, count: number): VisibleRange {
551
+ if (count <= 0) return { start: 0, end: 0 };
552
+ const start = Math.max(0, current - VIRTUAL_OVERSCAN);
553
+ const end = Math.min(count, current + VIRTUAL_OVERSCAN + 1);
554
+ return { start, end };
555
+ }
556
+
557
+ function scrollBehavior(): ScrollBehavior {
558
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
559
+ }
560
+
561
+ function ThumbContents({
562
+ index,
563
+ total,
564
+ active,
565
+ page: PageComp,
566
+ design,
567
+ scale,
568
+ thumbWidth,
569
+ height,
570
+ moduleTransition,
571
+ }: {
572
+ index: number;
573
+ total: number;
574
+ active: boolean;
575
+ page: Page;
576
+ design?: DesignSystem;
577
+ scale: number;
578
+ thumbWidth: number;
579
+ height: number;
580
+ moduleTransition?: SlideTransition;
581
+ }) {
582
+ const t = useLocale();
583
+ const boxRef = useRef<HTMLDivElement | null>(null);
584
+ const [hasSteps, setHasSteps] = useState(false);
585
+
586
+ // Steps live in JSX and can't be introspected statically — detect them from
587
+ // the already-rendered thumbnail DOM, where each Step emits `data-osd-step`.
588
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-detect when the page at this slot changes (reorder/edit reuses the index)
589
+ useEffect(() => {
590
+ setHasSteps(boxRef.current?.querySelector('[data-osd-step]') != null);
591
+ }, [PageComp]);
592
+
593
+ const hasTransition = Boolean(PageComp.transition ?? moduleTransition);
594
+
595
+ return (
596
+ <>
597
+ <div className="mt-1.5 flex w-7 shrink-0 flex-col items-end gap-1">
598
+ <span
599
+ className={cn(
600
+ 'font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
601
+ active ? 'text-brand' : 'text-muted-foreground/70',
602
+ )}
603
+ >
604
+ {(index + 1).toString().padStart(2, '0')}
605
+ </span>
606
+ {(hasTransition || hasSteps) && (
607
+ <div className="flex flex-col items-end gap-0.5">
608
+ {hasTransition && (
609
+ <ThumbIndicator icon={Sparkles} label={t.thumbnailRail.transitionIndicator} />
610
+ )}
611
+ {hasSteps && (
612
+ <ThumbIndicator icon={ListOrdered} label={t.thumbnailRail.stepsIndicator} />
613
+ )}
614
+ </div>
615
+ )}
616
+ </div>
617
+ <div
618
+ ref={boxRef}
619
+ className={cn(
620
+ 'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-[border-color,box-shadow]',
621
+ active
622
+ ? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
623
+ : 'border-hairline group-hover/thumb:border-foreground/25',
624
+ )}
625
+ style={{ width: thumbWidth, height }}
626
+ >
627
+ <SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
628
+ <SlidePageProvider index={index} total={total}>
629
+ <PageComp />
630
+ </SlidePageProvider>
631
+ </SlideCanvas>
632
+ {active && (
633
+ <span
634
+ aria-hidden
635
+ className="pointer-events-none absolute inset-y-0 left-0 w-[2px] bg-brand"
636
+ />
637
+ )}
638
+ </div>
639
+ </>
640
+ );
641
+ }
642
+
643
+ function ThumbIndicator({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
644
+ return (
645
+ <Tooltip>
646
+ <TooltipTrigger asChild>
647
+ <span
648
+ role="img"
649
+ aria-label={label}
650
+ className={cn(
651
+ 'flex size-3.5 items-center justify-center text-muted-foreground/55',
652
+ 'motion-safe:transition-colors group-hover/thumb:text-muted-foreground/80',
653
+ )}
654
+ >
655
+ <Icon className="size-3" strokeWidth={2} />
656
+ </span>
657
+ </TooltipTrigger>
658
+ <TooltipContent side="right" sideOffset={6}>
659
+ {label}
660
+ </TooltipContent>
661
+ </Tooltip>
662
+ );
663
+ }
664
+
665
+ function ThumbContextMenu({
666
+ index,
667
+ actions,
668
+ pageCount,
669
+ ariaLabel,
670
+ children,
671
+ }: {
672
+ index: number;
673
+ actions: ThumbnailActions;
674
+ pageCount: number;
675
+ ariaLabel: string;
676
+ children: React.ReactNode;
677
+ }) {
678
+ const t = useLocale();
679
+ const canDelete = pageCount > 1;
680
+ return (
681
+ <ContextMenu>
682
+ <ContextMenuTrigger asChild aria-label={ariaLabel}>
683
+ {children}
684
+ </ContextMenuTrigger>
685
+ <ContextMenuContent className="min-w-[180px]">
686
+ <ContextMenuItem onSelect={() => actions.onDuplicate(index)}>
687
+ <Copy />
688
+ {t.thumbnailRail.duplicatePage}
689
+ </ContextMenuItem>
690
+ <ContextMenuSeparator />
691
+ <ContextMenuItem
692
+ variant="destructive"
693
+ disabled={!canDelete}
694
+ onSelect={() => {
695
+ if (canDelete) actions.onDelete(index);
696
+ }}
697
+ >
698
+ <Trash2 />
699
+ {t.thumbnailRail.deletePage}
700
+ </ContextMenuItem>
701
+ </ContextMenuContent>
702
+ </ContextMenu>
703
+ );
704
+ }
705
+
706
+ function SortableRail({
707
+ pages,
708
+ onReorder,
709
+ onSelect,
710
+ children,
711
+ }: {
712
+ pages: Page[];
713
+ onReorder: (from: number, to: number) => void;
714
+ onSelect: (index: number) => void;
715
+ children: React.ReactNode;
716
+ }) {
717
+ const sensors = useSensors(
718
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
719
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
720
+ );
721
+
722
+ const items = pages.map((_, i) => i + 1);
723
+
724
+ const handleDragStart = (event: DragStartEvent) => {
725
+ const i = (event.active.id as number) - 1;
726
+ if (i >= 0) onSelect(i);
727
+ };
728
+
729
+ const handleDragEnd = (event: DragEndEvent) => {
730
+ const { active, over } = event;
731
+ if (!over || active.id === over.id) return;
732
+ const from = (active.id as number) - 1;
733
+ const to = (over.id as number) - 1;
734
+ if (from < 0 || to < 0 || from === to) return;
735
+ onReorder(from, to);
736
+ };
737
+
738
+ return (
739
+ <DndContext
740
+ sensors={sensors}
741
+ collisionDetection={closestCenter}
742
+ onDragStart={handleDragStart}
743
+ onDragEnd={handleDragEnd}
744
+ >
745
+ <SortableContext items={items} strategy={verticalListSortingStrategy}>
746
+ {children}
747
+ </SortableContext>
748
+ </DndContext>
749
+ );
750
+ }
751
+
752
+ function SortableThumb({
753
+ index,
754
+ active,
755
+ activeRef,
756
+ onSelect,
757
+ ariaLabel,
758
+ children,
759
+ ...rest
760
+ }: {
761
+ index: number;
762
+ active: boolean;
763
+ activeRef: React.MutableRefObject<HTMLButtonElement | null> | undefined;
764
+ onSelect: () => void;
765
+ ariaLabel: string;
766
+ children: React.ReactNode;
767
+ } & Omit<
768
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
769
+ 'onClick' | 'aria-label' | 'aria-current' | 'type' | 'style' | 'className' | 'ref' | 'children'
770
+ >) {
771
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
772
+ id: index + 1,
773
+ });
774
+
775
+ const setRef = (node: HTMLButtonElement | null) => {
776
+ setNodeRef(node);
777
+ if (activeRef) activeRef.current = node;
778
+ };
779
+
780
+ const yOnlyTransform = transform ? { ...transform, x: 0 } : transform;
781
+
782
+ return (
783
+ <button
784
+ {...rest}
785
+ ref={setRef}
786
+ type="button"
787
+ onClick={onSelect}
788
+ aria-label={ariaLabel}
789
+ aria-current={active ? 'true' : undefined}
790
+ style={{
791
+ transform: CSS.Transform.toString(yOnlyTransform),
792
+ transition,
793
+ touchAction: 'none',
794
+ }}
795
+ className={cn(
796
+ thumbButtonClass(active),
797
+ isDragging && 'z-10 cursor-grabbing opacity-60 shadow-edge ring-1 ring-brand',
798
+ )}
799
+ {...attributes}
800
+ {...listeners}
801
+ >
802
+ {children}
803
+ </button>
804
+ );
805
+ }