@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,120 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+
11
+ export type HistoryEntry = {
12
+ undo: () => void;
13
+ redo: () => void;
14
+ coalesceKey?: string;
15
+ ts: number;
16
+ };
17
+
18
+ type HistoryCtx = {
19
+ canUndo: boolean;
20
+ canRedo: boolean;
21
+ record: (entry: Omit<HistoryEntry, 'ts'>) => void;
22
+ undo: () => void;
23
+ redo: () => void;
24
+ clear: () => void;
25
+ isSuppressed: () => boolean;
26
+ };
27
+
28
+ const COALESCE_WINDOW_MS = 500;
29
+
30
+ const Ctx = createContext<HistoryCtx | null>(null);
31
+
32
+ export function useHistory(): HistoryCtx {
33
+ const v = useContext(Ctx);
34
+ if (!v) throw new Error('useHistory must be used inside <HistoryProvider>');
35
+ return v;
36
+ }
37
+
38
+ export function HistoryProvider({ children }: { children: ReactNode }) {
39
+ const [past, setPast] = useState<HistoryEntry[]>([]);
40
+ const [future, setFuture] = useState<HistoryEntry[]>([]);
41
+ // Set while invoking an entry's undo/redo so providers can skip
42
+ // re-recording the resulting state mutation.
43
+ const suppressedRef = useRef(false);
44
+
45
+ const record = useCallback((entry: Omit<HistoryEntry, 'ts'>) => {
46
+ if (suppressedRef.current) return;
47
+ const ts = Date.now();
48
+ setPast((prev) => {
49
+ const top = prev.at(-1);
50
+ if (
51
+ top &&
52
+ entry.coalesceKey !== undefined &&
53
+ top.coalesceKey === entry.coalesceKey &&
54
+ ts - top.ts < COALESCE_WINDOW_MS
55
+ ) {
56
+ const merged: HistoryEntry = {
57
+ undo: top.undo,
58
+ redo: entry.redo,
59
+ coalesceKey: entry.coalesceKey,
60
+ ts,
61
+ };
62
+ return [...prev.slice(0, -1), merged];
63
+ }
64
+ return [...prev, { ...entry, ts }];
65
+ });
66
+ setFuture([]);
67
+ }, []);
68
+
69
+ const undo = useCallback(() => {
70
+ setPast((prev) => {
71
+ const top = prev.at(-1);
72
+ if (!top) return prev;
73
+ suppressedRef.current = true;
74
+ try {
75
+ top.undo();
76
+ } finally {
77
+ suppressedRef.current = false;
78
+ }
79
+ setFuture((f) => [...f, top]);
80
+ return prev.slice(0, -1);
81
+ });
82
+ }, []);
83
+
84
+ const redo = useCallback(() => {
85
+ setFuture((prev) => {
86
+ const top = prev.at(-1);
87
+ if (!top) return prev;
88
+ suppressedRef.current = true;
89
+ try {
90
+ top.redo();
91
+ } finally {
92
+ suppressedRef.current = false;
93
+ }
94
+ setPast((p) => [...p, top]);
95
+ return prev.slice(0, -1);
96
+ });
97
+ }, []);
98
+
99
+ const clear = useCallback(() => {
100
+ setPast([]);
101
+ setFuture([]);
102
+ }, []);
103
+
104
+ const isSuppressed = useCallback(() => suppressedRef.current, []);
105
+
106
+ const value = useMemo<HistoryCtx>(
107
+ () => ({
108
+ canUndo: past.length > 0,
109
+ canRedo: future.length > 0,
110
+ record,
111
+ undo,
112
+ redo,
113
+ clear,
114
+ isSuppressed,
115
+ }),
116
+ [past.length, future.length, record, undo, redo, clear, isSuppressed],
117
+ );
118
+
119
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
120
+ }
@@ -0,0 +1,243 @@
1
+ import { type CSSProperties, type HTMLAttributes, useRef, useState } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { uploadWithAutoRename } from '@/lib/assets';
4
+ import { useLocale } from '@/lib/use-locale';
5
+
6
+ export type ImagePlaceholderProps = {
7
+ hint: string;
8
+ width?: number;
9
+ height?: number;
10
+ style?: CSSProperties;
11
+ className?: string;
12
+ } & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'style' | 'className'>;
13
+
14
+ export function ImagePlaceholder({
15
+ hint,
16
+ width,
17
+ height,
18
+ style,
19
+ className,
20
+ ...rest
21
+ }: ImagePlaceholderProps) {
22
+ const dims = width && height ? `${width} × ${height}` : null;
23
+ const [dragActive, setDragActive] = useState(false);
24
+ const [uploading, setUploading] = useState(false);
25
+ const dragDepth = useRef(0);
26
+ const t = useLocale();
27
+
28
+ const dndProps = import.meta.env.DEV
29
+ ? {
30
+ onDragEnter: (e: React.DragEvent<HTMLDivElement>) => {
31
+ if (uploading || !hasImageFile(e)) return;
32
+ e.preventDefault();
33
+ dragDepth.current += 1;
34
+ setDragActive(true);
35
+ },
36
+ onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
37
+ if (uploading || !hasImageFile(e)) return;
38
+ e.preventDefault();
39
+ e.dataTransfer.dropEffect = 'copy';
40
+ },
41
+ onDragLeave: () => {
42
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
43
+ if (dragDepth.current === 0) setDragActive(false);
44
+ },
45
+ onDrop: (e: React.DragEvent<HTMLDivElement>) => {
46
+ if (uploading || !hasImageFile(e)) return;
47
+ e.preventDefault();
48
+ dragDepth.current = 0;
49
+ setDragActive(false);
50
+ const file = pickImageFile(e.dataTransfer.files);
51
+ if (!file) return;
52
+ const root = e.currentTarget;
53
+ const slideId = root.closest<HTMLElement>('[data-slide-id]')?.dataset.slideId;
54
+ const loc = root.dataset.slideLoc;
55
+ if (!slideId || !loc) return;
56
+ const idx = loc.indexOf(':');
57
+ if (idx <= 0) return;
58
+ const line = Number(loc.slice(0, idx));
59
+ const column = Number(loc.slice(idx + 1));
60
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
61
+ setUploading(true);
62
+ handleDrop(slideId, file, line, column)
63
+ .catch(() => toast.error(t.imagePlaceholder.uploadFailed))
64
+ .finally(() => setUploading(false));
65
+ },
66
+ }
67
+ : null;
68
+
69
+ return (
70
+ <div
71
+ {...rest}
72
+ {...dndProps}
73
+ data-slide-placeholder={hint}
74
+ data-placeholder-w={width}
75
+ data-placeholder-h={height}
76
+ role="img"
77
+ aria-label={hint}
78
+ style={{
79
+ position: 'relative',
80
+ width: width ?? '100%',
81
+ height: height ?? '100%',
82
+ display: 'flex',
83
+ alignItems: 'center',
84
+ justifyContent: 'center',
85
+ flexDirection: 'column',
86
+ gap: 14,
87
+ border: '1px dashed rgba(120, 120, 130, 0.35)',
88
+ borderRadius: 12,
89
+ background:
90
+ 'linear-gradient(135deg, rgba(120,120,130,0.06) 0%, rgba(120,120,130,0.02) 50%, rgba(120,120,130,0.06) 100%)',
91
+ color: 'rgba(90, 90, 100, 0.7)',
92
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif',
93
+ textAlign: 'center',
94
+ padding: 24,
95
+ boxSizing: 'border-box',
96
+ overflow: 'hidden',
97
+ ...style,
98
+ }}
99
+ className={className}
100
+ >
101
+ <PlaceholderIcon />
102
+ <div
103
+ style={{
104
+ display: 'flex',
105
+ flexDirection: 'column',
106
+ alignItems: 'center',
107
+ gap: 6,
108
+ maxWidth: '85%',
109
+ }}
110
+ >
111
+ <span
112
+ style={{
113
+ fontSize: 11,
114
+ fontWeight: 600,
115
+ letterSpacing: '0.14em',
116
+ textTransform: 'uppercase',
117
+ opacity: 0.55,
118
+ }}
119
+ >
120
+ Image
121
+ </span>
122
+ <span
123
+ style={{
124
+ fontSize: 16,
125
+ fontWeight: 500,
126
+ lineHeight: 1.4,
127
+ color: 'rgba(60, 60, 70, 0.85)',
128
+ }}
129
+ >
130
+ {hint}
131
+ </span>
132
+ {dims && (
133
+ <span
134
+ style={{
135
+ fontSize: 11,
136
+ fontVariantNumeric: 'tabular-nums',
137
+ fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
138
+ opacity: 0.5,
139
+ marginTop: 2,
140
+ }}
141
+ >
142
+ {dims}
143
+ </span>
144
+ )}
145
+ </div>
146
+ {import.meta.env.DEV && (dragActive || uploading) && (
147
+ <DropOverlay
148
+ label={uploading ? t.imagePlaceholder.uploading : t.imagePlaceholder.dropOverlay}
149
+ />
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function DropOverlay({ label }: { label: string }) {
156
+ return (
157
+ <div
158
+ aria-hidden
159
+ style={{
160
+ position: 'absolute',
161
+ inset: 0,
162
+ pointerEvents: 'none',
163
+ borderRadius: 12,
164
+ border: '2px dashed oklch(0.62 0.18 250)',
165
+ background: 'oklch(0.62 0.18 250 / 0.08)',
166
+ display: 'flex',
167
+ alignItems: 'center',
168
+ justifyContent: 'center',
169
+ }}
170
+ >
171
+ <span
172
+ style={{
173
+ fontSize: 12,
174
+ fontWeight: 600,
175
+ letterSpacing: '0.02em',
176
+ color: 'oklch(0.45 0.16 250)',
177
+ background: 'rgba(255,255,255,0.92)',
178
+ padding: '6px 10px',
179
+ borderRadius: 6,
180
+ boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
181
+ }}
182
+ >
183
+ {label}
184
+ </span>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ function hasImageFile(e: React.DragEvent): boolean {
190
+ const types = e.dataTransfer?.types;
191
+ if (!types) return false;
192
+ for (let i = 0; i < types.length; i++) {
193
+ if (types[i] === 'Files') return true;
194
+ }
195
+ return false;
196
+ }
197
+
198
+ function pickImageFile(files: FileList): File | null {
199
+ for (let i = 0; i < files.length; i++) {
200
+ const f = files[i];
201
+ if (f.type.startsWith('image/')) return f;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ async function handleDrop(slideId: string, file: File, line: number, column: number) {
207
+ const { ok, entry } = await uploadWithAutoRename(slideId, file);
208
+ if (!ok || !entry) throw new Error('upload failed');
209
+ const res = await fetch('/__edit', {
210
+ method: 'POST',
211
+ headers: { 'content-type': 'application/json' },
212
+ body: JSON.stringify({
213
+ slideId,
214
+ line,
215
+ column,
216
+ ops: [{ kind: 'replace-placeholder-with-image', assetPath: `./assets/${entry.name}` }],
217
+ }),
218
+ });
219
+ if (!res.ok) throw new Error(`edit failed (${res.status})`);
220
+ }
221
+
222
+ function PlaceholderIcon() {
223
+ return (
224
+ <svg
225
+ width="32"
226
+ height="32"
227
+ viewBox="0 0 32 32"
228
+ fill="none"
229
+ stroke="currentColor"
230
+ strokeWidth="1.5"
231
+ strokeLinecap="round"
232
+ strokeLinejoin="round"
233
+ style={{ opacity: 0.55 }}
234
+ role="img"
235
+ aria-label="image placeholder"
236
+ >
237
+ <title>image placeholder</title>
238
+ <rect x="4" y="6" width="24" height="20" rx="2.5" />
239
+ <circle cx="11" cy="13" r="2" />
240
+ <path d="M4 22l7-7 6 6 4-4 7 7" />
241
+ </svg>
242
+ );
243
+ }
@@ -0,0 +1,196 @@
1
+ import { ArrowDownToLine, Loader2, Upload } from 'lucide-react';
2
+ import type React from 'react';
3
+ import { useCallback, useId, useRef, useState } from 'react';
4
+ import { toast } from 'sonner';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '@/components/ui/dialog';
12
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
13
+ import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
14
+ import { format, useLocale } from '@/lib/use-locale';
15
+ import { cn } from '@/lib/utils';
16
+
17
+ export type PickerScope = 'slide' | 'global';
18
+ const GLOBAL_PICKER_SLIDE_ID = '@global';
19
+
20
+ export function AssetPickerDialog({
21
+ slideId,
22
+ onClose,
23
+ onPick,
24
+ }: {
25
+ slideId: string;
26
+ onClose: () => void;
27
+ onPick: (asset: AssetEntry, scope: PickerScope) => void;
28
+ }) {
29
+ const [scope, setScope] = useState<PickerScope>('slide');
30
+ const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId;
31
+ const { assets, loading, refresh } = useAssets(effectiveSlideId);
32
+ const images = assets.filter((a) => a.mime.startsWith('image/'));
33
+ const t = useLocale();
34
+ const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
35
+ const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
36
+ const [uploading, setUploading] = useState(false);
37
+ const [dragActive, setDragActive] = useState(false);
38
+ const dragDepth = useRef(0);
39
+ const inputId = useId();
40
+
41
+ const handleFile = useCallback(
42
+ async (file: File) => {
43
+ if (!file.type.startsWith('image/')) return;
44
+ setUploading(true);
45
+ try {
46
+ const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
47
+ if (!ok || !entry) {
48
+ toast.error(format(t.asset.toastUploadFailed, { status }));
49
+ return;
50
+ }
51
+ await refresh().catch(() => {});
52
+ onPick(entry, scope);
53
+ } finally {
54
+ setUploading(false);
55
+ }
56
+ },
57
+ [effectiveSlideId, scope, refresh, onPick, t],
58
+ );
59
+
60
+ return (
61
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
62
+ <DialogContent className="sm:max-w-xl">
63
+ <DialogHeader>
64
+ <DialogTitle>{t.inspector.replaceImageDialogTitle}</DialogTitle>
65
+ <DialogDescription>
66
+ {descPrefix}
67
+ <span className="font-mono">{path}</span>
68
+ {descSuffix}
69
+ </DialogDescription>
70
+ </DialogHeader>
71
+ <Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
72
+ <TabsList>
73
+ <TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
74
+ <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
75
+ </TabsList>
76
+ </Tabs>
77
+ <label
78
+ htmlFor={inputId}
79
+ className={cn(
80
+ 'absolute right-12 top-3.5 inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[5px] border border-border bg-card px-2 text-[12px] font-medium transition-colors',
81
+ 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
82
+ uploading && 'pointer-events-none opacity-60',
83
+ )}
84
+ >
85
+ {uploading ? (
86
+ <Loader2 className="size-3.5 animate-spin" />
87
+ ) : (
88
+ <Upload className="size-3.5" />
89
+ )}
90
+ <span>{t.asset.upload}</span>
91
+ </label>
92
+ <input
93
+ id={inputId}
94
+ type="file"
95
+ accept="image/*"
96
+ className="sr-only"
97
+ disabled={uploading}
98
+ onChange={(e) => {
99
+ const file = e.target.files?.[0];
100
+ e.target.value = '';
101
+ if (file) handleFile(file).catch(() => {});
102
+ }}
103
+ />
104
+ <section
105
+ aria-label={t.inspector.replaceImageDialogTitle}
106
+ className="relative max-h-[60vh] overflow-y-auto"
107
+ onDragEnter={(e) => {
108
+ if (uploading || !hasFiles(e)) return;
109
+ e.preventDefault();
110
+ dragDepth.current += 1;
111
+ setDragActive(true);
112
+ }}
113
+ onDragOver={(e) => {
114
+ if (uploading || !hasFiles(e)) return;
115
+ e.preventDefault();
116
+ e.dataTransfer.dropEffect = 'copy';
117
+ }}
118
+ onDragLeave={() => {
119
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
120
+ if (dragDepth.current === 0) setDragActive(false);
121
+ }}
122
+ onDrop={(e) => {
123
+ if (uploading || !hasFiles(e)) return;
124
+ e.preventDefault();
125
+ dragDepth.current = 0;
126
+ setDragActive(false);
127
+ const file = e.dataTransfer.files?.[0];
128
+ if (file) handleFile(file).catch(() => {});
129
+ }}
130
+ >
131
+ {loading ? (
132
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
133
+ {t.inspector.pickerLoading}
134
+ </p>
135
+ ) : images.length === 0 ? (
136
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
137
+ {t.inspector.pickerEmpty}
138
+ </p>
139
+ ) : (
140
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
141
+ {images.map((asset) => (
142
+ <button
143
+ key={asset.name}
144
+ type="button"
145
+ onClick={() => onPick(asset, scope)}
146
+ className={cn(
147
+ 'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
148
+ 'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
149
+ )}
150
+ >
151
+ <div className="flex aspect-square w-full items-center justify-center overflow-hidden bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:12px_12px]">
152
+ <img
153
+ src={asset.url}
154
+ alt=""
155
+ className="size-full object-contain"
156
+ draggable={false}
157
+ />
158
+ </div>
159
+ <div className="border-t px-2 py-1.5">
160
+ <div className="truncate text-[11px] font-medium" title={asset.name}>
161
+ {asset.name}
162
+ </div>
163
+ </div>
164
+ </button>
165
+ ))}
166
+ </div>
167
+ )}
168
+ {dragActive && (
169
+ <div
170
+ className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
171
+ aria-hidden
172
+ >
173
+ <div className="absolute inset-0 bg-brand/5" />
174
+ <div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
175
+ <div className="absolute inset-x-0 bottom-4 flex justify-center">
176
+ <div className="flex items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating">
177
+ <ArrowDownToLine className="size-3.5 text-brand" />
178
+ <span>{t.asset.dropToUpload}</span>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ )}
183
+ </section>
184
+ </DialogContent>
185
+ </Dialog>
186
+ );
187
+ }
188
+
189
+ function hasFiles(e: React.DragEvent): boolean {
190
+ const types = e.dataTransfer?.types;
191
+ if (!types) return false;
192
+ for (let i = 0; i < types.length; i++) {
193
+ if (types[i] === 'Files') return true;
194
+ }
195
+ return false;
196
+ }
@@ -0,0 +1,93 @@
1
+ import { MessageSquare, Trash2, X } from 'lucide-react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { format, plural, useLocale } from '@/lib/use-locale';
4
+ import { useInspector } from './inspector-provider';
5
+
6
+ export function CommentWidget() {
7
+ const t = useLocale();
8
+ const { comments, remove, error } = useInspector();
9
+ const [open, setOpen] = useState(false);
10
+ const count = comments.length;
11
+ const ref = useRef<HTMLDivElement>(null);
12
+
13
+ useEffect(() => {
14
+ if (!open) return;
15
+ const onPointerDown = (e: PointerEvent) => {
16
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
17
+ };
18
+ document.addEventListener('pointerdown', onPointerDown);
19
+ return () => document.removeEventListener('pointerdown', onPointerDown);
20
+ }, [open]);
21
+
22
+ return (
23
+ <div
24
+ ref={ref}
25
+ data-inspector-ui
26
+ className="absolute right-4 bottom-4 z-20 flex flex-col items-end gap-2"
27
+ >
28
+ {open && (
29
+ <div className="w-80 rounded-md border bg-card shadow-xl animate-in fade-in-0 slide-in-from-bottom-2 duration-200">
30
+ <div className="flex items-center justify-between border-b px-3 py-2">
31
+ <span className="text-xs font-semibold">
32
+ {format(plural(count, t.inspector.commentsCount), { count })}
33
+ </span>
34
+ <button
35
+ type="button"
36
+ className="text-muted-foreground hover:text-foreground"
37
+ onClick={() => setOpen(false)}
38
+ >
39
+ <X className="size-3.5" />
40
+ </button>
41
+ </div>
42
+ {error && <p className="px-3 py-2 text-xs text-red-600">{error}</p>}
43
+ {count === 0 ? (
44
+ <p className="px-3 py-6 text-center text-xs text-muted-foreground">
45
+ {t.inspector.commentsEmpty}
46
+ </p>
47
+ ) : (
48
+ <>
49
+ <ul className="max-h-72 overflow-auto">
50
+ {comments.map((c) => (
51
+ <li
52
+ key={c.id}
53
+ className="flex items-start gap-2 border-b px-3 py-2 last:border-0"
54
+ >
55
+ <div className="min-w-0 flex-1">
56
+ <div className="text-[10px] font-mono text-muted-foreground">
57
+ {format(t.inspector.commentLineLabel, { n: c.line })}
58
+ </div>
59
+ <div className="mt-0.5 text-xs break-words">{c.note}</div>
60
+ </div>
61
+ <button
62
+ type="button"
63
+ onClick={() => remove(c.id)}
64
+ className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-red-600"
65
+ title={t.inspector.commentDeleteAria}
66
+ >
67
+ <Trash2 className="size-3.5" />
68
+ </button>
69
+ </li>
70
+ ))}
71
+ </ul>
72
+ <div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
73
+ {t.inspector.commentsApplyHintPrefix}
74
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
75
+ /apply-comments
76
+ </code>
77
+ {t.inspector.commentsApplyHintSuffix}
78
+ </div>
79
+ </>
80
+ )}
81
+ </div>
82
+ )}
83
+ <button
84
+ type="button"
85
+ onClick={() => setOpen((o) => !o)}
86
+ className="flex items-center gap-2 rounded-full border bg-card px-3 py-2 text-xs font-medium shadow-lg hover:bg-muted"
87
+ >
88
+ <MessageSquare className="size-4" />
89
+ {count}
90
+ </button>
91
+ </div>
92
+ );
93
+ }