@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,48 @@
|
|
|
1
|
+
import { useHistory } from '@/components/history-provider';
|
|
2
|
+
import { SaveCard } from '@/components/panel/save-card';
|
|
3
|
+
import { useDesignPanelState } from '@/components/style-panel/design-provider';
|
|
4
|
+
import { format, plural, useLocale } from '@/lib/use-locale';
|
|
5
|
+
import { useInspector } from './inspector-provider';
|
|
6
|
+
|
|
7
|
+
export function SaveBar() {
|
|
8
|
+
const insp = useInspector();
|
|
9
|
+
const design = useDesignPanelState();
|
|
10
|
+
const history = useHistory();
|
|
11
|
+
const t = useLocale();
|
|
12
|
+
|
|
13
|
+
const inspectorCount = insp.pendingCount;
|
|
14
|
+
const designCount = design.dirty ? 1 : 0;
|
|
15
|
+
const total = inspectorCount + designCount;
|
|
16
|
+
|
|
17
|
+
const dirty = total > 0;
|
|
18
|
+
const committing = insp.committing || design.committing;
|
|
19
|
+
|
|
20
|
+
const onSave = async () => {
|
|
21
|
+
const tasks: Promise<void>[] = [];
|
|
22
|
+
if (inspectorCount > 0) tasks.push(Promise.resolve(insp.commitEdits()));
|
|
23
|
+
if (designCount > 0) tasks.push(Promise.resolve(design.commit()));
|
|
24
|
+
// Each provider surfaces its own errors via toast; swallow here so
|
|
25
|
+
// one failure doesn't reject the combined save.
|
|
26
|
+
await Promise.all(tasks).catch(() => {});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const onDiscard = () => {
|
|
30
|
+
if (inspectorCount > 0) insp.cancelEdits();
|
|
31
|
+
if (designCount > 0) design.discard();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<SaveCard
|
|
36
|
+
uiAttr="inspector"
|
|
37
|
+
dirty={dirty}
|
|
38
|
+
committing={committing}
|
|
39
|
+
onSave={onSave}
|
|
40
|
+
onDiscard={onDiscard}
|
|
41
|
+
unsavedLabel={format(plural(total, t.inspector.unsavedChanges), { count: total })}
|
|
42
|
+
onUndo={history.undo}
|
|
43
|
+
onRedo={history.redo}
|
|
44
|
+
canUndo={history.canUndo}
|
|
45
|
+
canRedo={history.canRedo}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Languages } from 'lucide-react';
|
|
2
|
+
import { buttonVariants } from '@/components/ui/button';
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuTrigger,
|
|
8
|
+
} from '@/components/ui/dropdown-menu';
|
|
9
|
+
import { LOCALE_OPTIONS, setLocale } from '@/lib/locale-store';
|
|
10
|
+
import { useLocale } from '@/lib/use-locale';
|
|
11
|
+
import { cn } from '@/lib/utils';
|
|
12
|
+
|
|
13
|
+
export function LanguageToggle() {
|
|
14
|
+
const t = useLocale();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<DropdownMenu>
|
|
18
|
+
<DropdownMenuTrigger
|
|
19
|
+
type="button"
|
|
20
|
+
aria-label={t.languageToggle.toggleAria}
|
|
21
|
+
title={t.languageToggle.title}
|
|
22
|
+
className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }))}
|
|
23
|
+
>
|
|
24
|
+
<Languages className="size-3.5" />
|
|
25
|
+
</DropdownMenuTrigger>
|
|
26
|
+
<DropdownMenuContent align="end" className="min-w-[140px]">
|
|
27
|
+
{LOCALE_OPTIONS.map((option) => (
|
|
28
|
+
<DropdownMenuItem
|
|
29
|
+
key={option.id}
|
|
30
|
+
onSelect={() => setLocale(option.id)}
|
|
31
|
+
data-active={t.id === option.id}
|
|
32
|
+
>
|
|
33
|
+
{option.label}
|
|
34
|
+
</DropdownMenuItem>
|
|
35
|
+
))}
|
|
36
|
+
</DropdownMenuContent>
|
|
37
|
+
</DropdownMenu>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ChevronDown, ChevronUp, NotebookPen } from 'lucide-react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { PANEL_TRANSITION_MS, usePanelMount } from '@/components/panel/panel-shell';
|
|
4
|
+
import { useNotes } from '@/lib/inspector/use-notes';
|
|
5
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'open-aippt:notes-drawer-open';
|
|
9
|
+
const DRAWER_CONTENT_H = 166;
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
slideId: string;
|
|
13
|
+
index: number;
|
|
14
|
+
total: number;
|
|
15
|
+
initial: string | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function NotesDrawer({ slideId, index, total, initial }: Props) {
|
|
19
|
+
const t = useLocale();
|
|
20
|
+
const [open, setOpen] = useState(() => {
|
|
21
|
+
if (typeof window === 'undefined') return false;
|
|
22
|
+
return window.localStorage.getItem(STORAGE_KEY) === '1';
|
|
23
|
+
});
|
|
24
|
+
const { value, setValue, status, flush } = useNotes(slideId, index, initial);
|
|
25
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
26
|
+
const { mounted, animVisible } = usePanelMount(open);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (typeof window === 'undefined') return;
|
|
30
|
+
window.localStorage.setItem(STORAGE_KEY, open ? '1' : '0');
|
|
31
|
+
}, [open]);
|
|
32
|
+
|
|
33
|
+
const statusLabel = (() => {
|
|
34
|
+
switch (status.kind) {
|
|
35
|
+
case 'saving':
|
|
36
|
+
return t.notesDrawer.statusSaving;
|
|
37
|
+
case 'saved':
|
|
38
|
+
return t.notesDrawer.statusSaved;
|
|
39
|
+
case 'error':
|
|
40
|
+
return format(t.notesDrawer.statusError, { msg: status.message });
|
|
41
|
+
default:
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<aside
|
|
48
|
+
data-notes-drawer
|
|
49
|
+
className="hidden shrink-0 border-t border-hairline bg-sidebar/85 backdrop-blur md:block"
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => {
|
|
54
|
+
setOpen((o) => {
|
|
55
|
+
if (o) void flush();
|
|
56
|
+
return !o;
|
|
57
|
+
});
|
|
58
|
+
}}
|
|
59
|
+
className="flex h-9 w-full items-center gap-2 px-3 text-[12px] text-foreground/80 hover:bg-muted/40"
|
|
60
|
+
aria-expanded={open}
|
|
61
|
+
>
|
|
62
|
+
<NotebookPen className="size-3.5 text-muted-foreground" />
|
|
63
|
+
<span className="font-medium">{t.notesDrawer.toggle}</span>
|
|
64
|
+
<span className="font-mono text-[11px] text-muted-foreground">
|
|
65
|
+
{format(t.notesDrawer.pageLabel, { n: index + 1, total })}
|
|
66
|
+
</span>
|
|
67
|
+
<span
|
|
68
|
+
className={cn(
|
|
69
|
+
'ml-auto truncate text-[11px]',
|
|
70
|
+
status.kind === 'error' ? 'text-destructive' : 'text-muted-foreground',
|
|
71
|
+
)}
|
|
72
|
+
aria-live="polite"
|
|
73
|
+
>
|
|
74
|
+
{statusLabel}
|
|
75
|
+
</span>
|
|
76
|
+
{open ? (
|
|
77
|
+
<ChevronDown className="size-3.5 text-muted-foreground" />
|
|
78
|
+
) : (
|
|
79
|
+
<ChevronUp className="size-3.5 text-muted-foreground" />
|
|
80
|
+
)}
|
|
81
|
+
</button>
|
|
82
|
+
{mounted && (
|
|
83
|
+
<div
|
|
84
|
+
className="overflow-hidden border-t border-hairline transition-[height] ease-out"
|
|
85
|
+
style={{
|
|
86
|
+
height: animVisible ? DRAWER_CONTENT_H : 0,
|
|
87
|
+
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<div className="px-3 py-2">
|
|
91
|
+
<textarea
|
|
92
|
+
ref={textareaRef}
|
|
93
|
+
value={value}
|
|
94
|
+
onChange={(e) => setValue(e.target.value)}
|
|
95
|
+
onBlur={() => {
|
|
96
|
+
void flush();
|
|
97
|
+
}}
|
|
98
|
+
onKeyDown={(e) => {
|
|
99
|
+
if (e.key === 'Escape') {
|
|
100
|
+
// Escape during IME composition dismisses the candidate
|
|
101
|
+
// list; it must not blur the textarea.
|
|
102
|
+
if (e.nativeEvent.isComposing) return;
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
textareaRef.current?.blur();
|
|
105
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
void flush();
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
placeholder={t.notesDrawer.placeholder}
|
|
111
|
+
rows={6}
|
|
112
|
+
spellCheck
|
|
113
|
+
className="block h-[150px] w-full resize-none rounded-[6px] border border-border bg-card px-3 py-2 text-[13px] leading-relaxed text-card-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</aside>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { ListOrdered, type LucideIcon, Sparkles, X } from 'lucide-react';
|
|
2
|
+
import { type Ref, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
4
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import type { DesignSystem } from '../lib/design';
|
|
7
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
8
|
+
import type { Page } from '../lib/sdk';
|
|
9
|
+
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
10
|
+
import type { SlideTransition } from '../lib/transition';
|
|
11
|
+
import { SlideCanvas } from './slide-canvas';
|
|
12
|
+
|
|
13
|
+
const THUMB_W = 320;
|
|
14
|
+
const THUMB_H = (THUMB_W * CANVAS_HEIGHT) / CANVAS_WIDTH;
|
|
15
|
+
|
|
16
|
+
export type OverviewVariant = 'present' | 'editor';
|
|
17
|
+
|
|
18
|
+
type Props = {
|
|
19
|
+
pages: Page[];
|
|
20
|
+
design?: DesignSystem;
|
|
21
|
+
open: boolean;
|
|
22
|
+
current: number;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
onSelect: (index: number) => void;
|
|
25
|
+
variant?: OverviewVariant;
|
|
26
|
+
moduleTransition?: SlideTransition;
|
|
27
|
+
tooltipContainer?: HTMLElement | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function OverviewGrid({
|
|
31
|
+
pages,
|
|
32
|
+
design,
|
|
33
|
+
open,
|
|
34
|
+
current,
|
|
35
|
+
onClose,
|
|
36
|
+
onSelect,
|
|
37
|
+
variant = 'present',
|
|
38
|
+
moduleTransition,
|
|
39
|
+
tooltipContainer,
|
|
40
|
+
}: Props) {
|
|
41
|
+
const [focused, setFocused] = useState(current);
|
|
42
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const focusedRef = useRef<HTMLButtonElement | null>(null);
|
|
44
|
+
const t = useLocale();
|
|
45
|
+
|
|
46
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: only re-sync on open transition
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (open) setFocused(current);
|
|
49
|
+
}, [open]);
|
|
50
|
+
|
|
51
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `focused` swaps which button holds the ref; we must re-run to focus the new node
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
focusedRef.current?.focus();
|
|
55
|
+
focusedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
56
|
+
}, [focused, open]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!open) return;
|
|
60
|
+
const onKey = (e: KeyboardEvent) => {
|
|
61
|
+
if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
|
|
62
|
+
const cols = computeCols(gridRef.current);
|
|
63
|
+
if (e.key === 'ArrowRight') {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
setFocused((i) => Math.min(pages.length - 1, i + 1));
|
|
67
|
+
} else if (e.key === 'ArrowLeft') {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
setFocused((i) => Math.max(0, i - 1));
|
|
71
|
+
} else if (e.key === 'ArrowDown') {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
setFocused((i) => Math.min(pages.length - 1, i + cols));
|
|
75
|
+
} else if (e.key === 'ArrowUp') {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
setFocused((i) => Math.max(0, i - cols));
|
|
79
|
+
} else if (e.key === 'Home') {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
setFocused(0);
|
|
83
|
+
} else if (e.key === 'End') {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
e.stopPropagation();
|
|
86
|
+
setFocused(pages.length - 1);
|
|
87
|
+
} else if (e.key === 'Enter') {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
e.stopPropagation();
|
|
90
|
+
onSelect(focused);
|
|
91
|
+
onClose();
|
|
92
|
+
} else if (e.key === 'Escape') {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
e.stopPropagation();
|
|
95
|
+
onClose();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
window.addEventListener('keydown', onKey, true);
|
|
99
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
100
|
+
}, [open, pages.length, focused, onClose, onSelect]);
|
|
101
|
+
|
|
102
|
+
if (!open) return null;
|
|
103
|
+
|
|
104
|
+
const styles = variant === 'present' ? presentStyles : editorStyles;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-modal="true"
|
|
110
|
+
aria-label={t.present.overviewDialogAria}
|
|
111
|
+
className={cn('absolute inset-0 z-50 flex flex-col backdrop-blur-sm', styles.surface)}
|
|
112
|
+
>
|
|
113
|
+
<div className="flex shrink-0 items-center justify-between px-8 pt-6 pb-3">
|
|
114
|
+
<span className={cn('eyebrow', styles.eyebrow)}>{t.present.overviewEyebrow}</span>
|
|
115
|
+
<div className="flex items-center gap-3">
|
|
116
|
+
<span className={cn('font-mono text-[11px] tabular-nums', styles.eyebrow)}>
|
|
117
|
+
{(focused + 1).toString().padStart(2, '0')} · {pages.length.toString().padStart(2, '0')}
|
|
118
|
+
</span>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={onClose}
|
|
122
|
+
aria-label={t.common.close}
|
|
123
|
+
className={cn(
|
|
124
|
+
'flex size-6 items-center justify-center rounded-[4px] outline-none transition-colors',
|
|
125
|
+
styles.closeButton,
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
<X className="size-3.5" />
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
|
|
133
|
+
<TooltipProvider delayDuration={200}>
|
|
134
|
+
<div
|
|
135
|
+
className="grid justify-center gap-5"
|
|
136
|
+
style={{
|
|
137
|
+
gridTemplateColumns: `repeat(auto-fill, ${THUMB_W}px)`,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{pages.map((PageComp, i) => {
|
|
141
|
+
const isFocused = i === focused;
|
|
142
|
+
const isCurrent = i === current;
|
|
143
|
+
return (
|
|
144
|
+
<OverviewThumb
|
|
145
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
|
|
146
|
+
key={i}
|
|
147
|
+
buttonRef={isFocused ? focusedRef : undefined}
|
|
148
|
+
page={PageComp}
|
|
149
|
+
index={i}
|
|
150
|
+
total={pages.length}
|
|
151
|
+
design={design}
|
|
152
|
+
isFocused={isFocused}
|
|
153
|
+
isCurrent={isCurrent}
|
|
154
|
+
styles={styles}
|
|
155
|
+
moduleTransition={moduleTransition}
|
|
156
|
+
tooltipContainer={tooltipContainer}
|
|
157
|
+
onFocus={() => setFocused(i)}
|
|
158
|
+
onSelect={() => {
|
|
159
|
+
onSelect(i);
|
|
160
|
+
onClose();
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</div>
|
|
166
|
+
</TooltipProvider>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function OverviewThumb({
|
|
173
|
+
page: PageComp,
|
|
174
|
+
index,
|
|
175
|
+
total,
|
|
176
|
+
design,
|
|
177
|
+
isFocused,
|
|
178
|
+
isCurrent,
|
|
179
|
+
styles,
|
|
180
|
+
moduleTransition,
|
|
181
|
+
tooltipContainer,
|
|
182
|
+
onFocus,
|
|
183
|
+
onSelect,
|
|
184
|
+
buttonRef,
|
|
185
|
+
}: {
|
|
186
|
+
page: Page;
|
|
187
|
+
index: number;
|
|
188
|
+
total: number;
|
|
189
|
+
design?: DesignSystem;
|
|
190
|
+
isFocused: boolean;
|
|
191
|
+
isCurrent: boolean;
|
|
192
|
+
styles: OverviewStyles;
|
|
193
|
+
moduleTransition?: SlideTransition;
|
|
194
|
+
tooltipContainer?: HTMLElement | null;
|
|
195
|
+
onFocus: () => void;
|
|
196
|
+
onSelect: () => void;
|
|
197
|
+
buttonRef?: Ref<HTMLButtonElement>;
|
|
198
|
+
}) {
|
|
199
|
+
const t = useLocale();
|
|
200
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
201
|
+
const [hasSteps, setHasSteps] = useState(false);
|
|
202
|
+
const hasTransition = Boolean(PageComp.transition ?? moduleTransition);
|
|
203
|
+
|
|
204
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-detect when the page at this slot changes
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
setHasSteps(boxRef.current?.querySelector('[data-osd-step]') != null);
|
|
207
|
+
}, [PageComp]);
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<button
|
|
211
|
+
ref={buttonRef}
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={onSelect}
|
|
214
|
+
onMouseEnter={onFocus}
|
|
215
|
+
onFocus={onFocus}
|
|
216
|
+
aria-label={format(t.present.overviewGoToAria, { n: index + 1 })}
|
|
217
|
+
aria-current={isCurrent ? 'true' : undefined}
|
|
218
|
+
className={cn(
|
|
219
|
+
'group/thumb flex flex-col items-start gap-2 rounded-[6px] p-1.5 outline-none transition-colors',
|
|
220
|
+
isFocused ? styles.thumbFocused : styles.thumbHover,
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
<div
|
|
224
|
+
ref={boxRef}
|
|
225
|
+
className={cn(
|
|
226
|
+
'relative w-full overflow-hidden rounded-[4px] ring-1 transition-shadow',
|
|
227
|
+
styles.thumbSurface,
|
|
228
|
+
isFocused ? 'ring-2 ring-[var(--brand,#ef4444)]' : styles.thumbRing,
|
|
229
|
+
)}
|
|
230
|
+
style={{ height: THUMB_H }}
|
|
231
|
+
>
|
|
232
|
+
<SlideCanvas
|
|
233
|
+
scale={THUMB_W / CANVAS_WIDTH}
|
|
234
|
+
center={false}
|
|
235
|
+
flat
|
|
236
|
+
freezeMotion
|
|
237
|
+
design={design}
|
|
238
|
+
>
|
|
239
|
+
<SlidePageProvider index={index} total={total}>
|
|
240
|
+
<PageComp />
|
|
241
|
+
</SlidePageProvider>
|
|
242
|
+
</SlideCanvas>
|
|
243
|
+
{isCurrent && (
|
|
244
|
+
<span
|
|
245
|
+
aria-hidden
|
|
246
|
+
className="pointer-events-none absolute top-1.5 right-1.5 rounded-[3px] bg-[var(--brand,#ef4444)] px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.06em] uppercase text-white"
|
|
247
|
+
>
|
|
248
|
+
{t.present.nowBadge}
|
|
249
|
+
</span>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
<div className="flex h-4 w-full items-center justify-between gap-2">
|
|
253
|
+
<span
|
|
254
|
+
className={cn(
|
|
255
|
+
'font-mono text-[10.5px] tracking-[0.08em] tabular-nums uppercase',
|
|
256
|
+
isFocused || isCurrent ? styles.labelActive : styles.labelMuted,
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
259
|
+
{(index + 1).toString().padStart(2, '0')}
|
|
260
|
+
</span>
|
|
261
|
+
{(hasTransition || hasSteps) && (
|
|
262
|
+
<span className="flex items-center gap-1">
|
|
263
|
+
{hasTransition && (
|
|
264
|
+
<OverviewIndicator
|
|
265
|
+
icon={Sparkles}
|
|
266
|
+
label={t.thumbnailRail.transitionIndicator}
|
|
267
|
+
className={styles.indicator}
|
|
268
|
+
tooltipContainer={tooltipContainer}
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
{hasSteps && (
|
|
272
|
+
<OverviewIndicator
|
|
273
|
+
icon={ListOrdered}
|
|
274
|
+
label={t.thumbnailRail.stepsIndicator}
|
|
275
|
+
className={styles.indicator}
|
|
276
|
+
tooltipContainer={tooltipContainer}
|
|
277
|
+
/>
|
|
278
|
+
)}
|
|
279
|
+
</span>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
</button>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function OverviewIndicator({
|
|
287
|
+
icon: Icon,
|
|
288
|
+
label,
|
|
289
|
+
className,
|
|
290
|
+
tooltipContainer,
|
|
291
|
+
}: {
|
|
292
|
+
icon: LucideIcon;
|
|
293
|
+
label: string;
|
|
294
|
+
className: string;
|
|
295
|
+
tooltipContainer?: HTMLElement | null;
|
|
296
|
+
}) {
|
|
297
|
+
return (
|
|
298
|
+
<Tooltip>
|
|
299
|
+
<TooltipTrigger asChild>
|
|
300
|
+
<span
|
|
301
|
+
role="img"
|
|
302
|
+
aria-label={label}
|
|
303
|
+
className={cn('flex size-4 items-center justify-center', className)}
|
|
304
|
+
>
|
|
305
|
+
<Icon className="size-3.5" strokeWidth={1.9} />
|
|
306
|
+
</span>
|
|
307
|
+
</TooltipTrigger>
|
|
308
|
+
<TooltipContent side="top" sideOffset={6} container={tooltipContainer ?? undefined}>
|
|
309
|
+
{label}
|
|
310
|
+
</TooltipContent>
|
|
311
|
+
</Tooltip>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
type OverviewStyles = {
|
|
316
|
+
surface: string;
|
|
317
|
+
eyebrow: string;
|
|
318
|
+
thumbFocused: string;
|
|
319
|
+
thumbHover: string;
|
|
320
|
+
thumbSurface: string;
|
|
321
|
+
thumbRing: string;
|
|
322
|
+
labelActive: string;
|
|
323
|
+
labelMuted: string;
|
|
324
|
+
indicator: string;
|
|
325
|
+
closeButton: string;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const presentStyles = {
|
|
329
|
+
surface: 'bg-black/95',
|
|
330
|
+
eyebrow: 'text-white/55',
|
|
331
|
+
thumbFocused: 'bg-white/10',
|
|
332
|
+
thumbHover: 'hover:bg-white/5',
|
|
333
|
+
thumbSurface: 'bg-black',
|
|
334
|
+
thumbRing: 'ring-white/10',
|
|
335
|
+
labelActive: 'text-white/85',
|
|
336
|
+
labelMuted: 'text-white/45',
|
|
337
|
+
indicator: 'text-white/45 transition-colors group-hover/thumb:text-white/75',
|
|
338
|
+
closeButton:
|
|
339
|
+
'text-white/55 hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/40',
|
|
340
|
+
} as const;
|
|
341
|
+
|
|
342
|
+
const editorStyles = {
|
|
343
|
+
surface: 'bg-background/95',
|
|
344
|
+
eyebrow: 'text-muted-foreground',
|
|
345
|
+
thumbFocused: 'bg-muted',
|
|
346
|
+
thumbHover: 'hover:bg-muted/60',
|
|
347
|
+
thumbSurface: 'bg-card',
|
|
348
|
+
thumbRing: 'ring-hairline',
|
|
349
|
+
labelActive: 'text-foreground',
|
|
350
|
+
labelMuted: 'text-muted-foreground/60',
|
|
351
|
+
indicator: 'text-muted-foreground/60 transition-colors group-hover/thumb:text-muted-foreground',
|
|
352
|
+
closeButton:
|
|
353
|
+
'text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
|
|
354
|
+
} as const;
|
|
355
|
+
|
|
356
|
+
function computeCols(grid: HTMLDivElement | null) {
|
|
357
|
+
if (!grid) return 4;
|
|
358
|
+
const inner = grid.firstElementChild as HTMLElement | null;
|
|
359
|
+
if (!inner) return 4;
|
|
360
|
+
const cs = getComputedStyle(inner);
|
|
361
|
+
const cols = cs.gridTemplateColumns.split(' ').filter(Boolean).length;
|
|
362
|
+
return Math.max(1, cols);
|
|
363
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Label } from '@/components/ui/label';
|
|
2
|
+
|
|
3
|
+
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
4
|
+
return (
|
|
5
|
+
<section className="px-3.5 py-3.5">
|
|
6
|
+
<div className="mb-2.5 flex items-center gap-2">
|
|
7
|
+
<span className="eyebrow">{title}</span>
|
|
8
|
+
<span aria-hidden className="h-px flex-1 bg-hairline" />
|
|
9
|
+
</div>
|
|
10
|
+
<div className="flex flex-col gap-2.5">{children}</div>
|
|
11
|
+
</section>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="grid grid-cols-[68px_1fr] items-center gap-3">
|
|
18
|
+
<Label className="text-[11px] font-normal text-muted-foreground">{label}</Label>
|
|
19
|
+
<div className="flex min-w-0 items-center gap-1.5">{children}</div>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function NumberField({
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
min,
|
|
28
|
+
max,
|
|
29
|
+
step = 1,
|
|
30
|
+
suffix,
|
|
31
|
+
}: {
|
|
32
|
+
value: number;
|
|
33
|
+
onChange: (n: number) => void;
|
|
34
|
+
min?: number;
|
|
35
|
+
max?: number;
|
|
36
|
+
step?: number;
|
|
37
|
+
suffix?: string;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex h-7 shrink-0 items-center rounded-[5px] border border-border bg-background pr-1.5 transition-colors focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/30">
|
|
41
|
+
<input
|
|
42
|
+
type="number"
|
|
43
|
+
value={value}
|
|
44
|
+
onChange={(e) => {
|
|
45
|
+
const n = Number(e.target.value);
|
|
46
|
+
if (Number.isFinite(n)) onChange(n);
|
|
47
|
+
}}
|
|
48
|
+
min={min}
|
|
49
|
+
max={max}
|
|
50
|
+
step={step}
|
|
51
|
+
className="nums h-full w-12 bg-transparent px-2 text-right font-mono text-[11px] outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
52
|
+
/>
|
|
53
|
+
{suffix && (
|
|
54
|
+
<span className="font-mono text-[9.5px] uppercase tracking-[0.06em] text-muted-foreground/80">
|
|
55
|
+
{suffix}
|
|
56
|
+
</span>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|