@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,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
|
+
}
|