@open-slide/core 1.1.0 → 1.3.0
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/dist/{build-DSqSio-T.js → build-_276DMmJ.js} +2 -2
- package/dist/cli/bin.js +5 -5
- package/dist/{config-KdiYeWtK.js → config-BAwKWNtW.js} +888 -229
- package/dist/{config-C7vMYzFD.d.ts → config-D9cZ1A0X.d.ts} +2 -1
- package/dist/{dev-B_GVbr11.js → dev-BoqeVXVq.js} +2 -2
- package/dist/en-CDKzoZvf.js +351 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +229 -39
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +166 -326
- package/dist/{preview-D_mxhj7w.js → preview-BLPxspc9.js} +2 -2
- package/dist/sync-j9_QPovT.js +3 -0
- package/dist/{types-DYgVpIGo.d.ts → types-JYG1cmwC.d.ts} +59 -5
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +2 -2
- package/package.json +9 -1
- package/skills/create-slide/SKILL.md +1 -1
- package/skills/create-theme/SKILL.md +60 -12
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +59 -1
- package/src/app/app.tsx +11 -1
- package/src/app/components/asset-view.tsx +1 -13
- package/src/app/components/image-placeholder.tsx +123 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
- package/src/app/components/inspector/inspector-panel.tsx +163 -19
- package/src/app/components/inspector/inspector-provider.tsx +60 -7
- package/src/app/components/notes-drawer.tsx +117 -0
- package/src/app/components/player.tsx +11 -7
- package/src/app/components/present/overview-grid.tsx +2 -2
- package/src/app/components/sidebar/folder-item.tsx +16 -5
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/components/themes/theme-detail.tsx +300 -0
- package/src/app/components/themes/themes-gallery.tsx +146 -0
- package/src/app/components/thumbnail-rail.tsx +136 -29
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/lib/assets.ts +55 -2
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/sdk.ts +1 -0
- package/src/app/lib/slides.ts +10 -1
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/routes/home-shell.tsx +173 -0
- package/src/app/routes/home.tsx +108 -204
- package/src/app/routes/slide.tsx +333 -68
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/virtual.d.ts +20 -0
- package/src/locale/en.ts +61 -7
- package/src/locale/ja.ts +62 -7
- package/src/locale/types.ts +62 -5
- package/src/locale/zh-cn.ts +61 -7
- package/src/locale/zh-tw.ts +61 -7
- package/dist/sync-B4eLo2H6.js +0 -3
- /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
- /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type
|
|
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';
|
|
2
5
|
|
|
3
6
|
export type ImagePlaceholderProps = {
|
|
4
7
|
hint: string;
|
|
@@ -17,9 +20,56 @@ export function ImagePlaceholder({
|
|
|
17
20
|
...rest
|
|
18
21
|
}: ImagePlaceholderProps) {
|
|
19
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
|
+
|
|
20
69
|
return (
|
|
21
70
|
<div
|
|
22
71
|
{...rest}
|
|
72
|
+
{...dndProps}
|
|
23
73
|
data-slide-placeholder={hint}
|
|
24
74
|
data-placeholder-w={width}
|
|
25
75
|
data-placeholder-h={height}
|
|
@@ -93,10 +143,82 @@ export function ImagePlaceholder({
|
|
|
93
143
|
</span>
|
|
94
144
|
)}
|
|
95
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>
|
|
96
185
|
</div>
|
|
97
186
|
);
|
|
98
187
|
}
|
|
99
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
|
+
|
|
100
222
|
function PlaceholderIcon() {
|
|
101
223
|
return (
|
|
102
224
|
<svg
|
|
@@ -13,11 +13,9 @@ import {
|
|
|
13
13
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
14
14
|
import { useLocale } from '@/lib/use-locale';
|
|
15
15
|
|
|
16
|
-
export type
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
y: number;
|
|
20
|
-
};
|
|
16
|
+
export type ImageCropRect = { x: number; y: number; width: number; height: number };
|
|
17
|
+
|
|
18
|
+
export type ImageCropResult = { fit: 'contain' } | { fit: 'cover'; rect: ImageCropRect };
|
|
21
19
|
|
|
22
20
|
export function ImageCropDialog({
|
|
23
21
|
src,
|
|
@@ -25,6 +23,7 @@ export function ImageCropDialog({
|
|
|
25
23
|
targetHeight,
|
|
26
24
|
initialFit,
|
|
27
25
|
initialPosition,
|
|
26
|
+
initialRect,
|
|
28
27
|
onClose,
|
|
29
28
|
onApply,
|
|
30
29
|
}: {
|
|
@@ -33,6 +32,7 @@ export function ImageCropDialog({
|
|
|
33
32
|
targetHeight: number;
|
|
34
33
|
initialFit: 'cover' | 'contain';
|
|
35
34
|
initialPosition: { x: number; y: number };
|
|
35
|
+
initialRect: ImageCropRect | null;
|
|
36
36
|
onClose: () => void;
|
|
37
37
|
onApply: (result: ImageCropResult) => void;
|
|
38
38
|
}) {
|
|
@@ -44,26 +44,30 @@ export function ImageCropDialog({
|
|
|
44
44
|
|
|
45
45
|
const onImageLoad = (e: SyntheticEvent<HTMLImageElement>) => {
|
|
46
46
|
const im = e.currentTarget;
|
|
47
|
-
setCrop(
|
|
47
|
+
setCrop(initialCrop(im.naturalWidth, im.naturalHeight, aspect, initialRect, initialPosition));
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
useEffect(() => {
|
|
51
51
|
const im = imgRef.current;
|
|
52
|
-
if (!im
|
|
52
|
+
if (!im?.complete || !im.naturalWidth || !im.naturalHeight) return;
|
|
53
53
|
setCrop((prev) => {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (prev && prev.unit === '%') {
|
|
55
|
+
return clampToAspect(prev as PercentCrop, aspect, im.naturalWidth, im.naturalHeight);
|
|
56
|
+
}
|
|
57
|
+
return initialCrop(im.naturalWidth, im.naturalHeight, aspect, initialRect, initialPosition);
|
|
56
58
|
});
|
|
57
|
-
}, [aspect, initialPosition]);
|
|
59
|
+
}, [aspect, initialPosition, initialRect]);
|
|
58
60
|
|
|
59
61
|
const onApplyClick = () => {
|
|
60
62
|
if (fit === 'contain') {
|
|
61
|
-
onApply({ fit
|
|
63
|
+
onApply({ fit });
|
|
62
64
|
return;
|
|
63
65
|
}
|
|
64
|
-
const
|
|
65
|
-
crop && crop.unit === '%'
|
|
66
|
-
|
|
66
|
+
const rect =
|
|
67
|
+
crop && crop.unit === '%'
|
|
68
|
+
? roundRect(crop as PercentCrop)
|
|
69
|
+
: { x: 0, y: 0, width: 100, height: 100 };
|
|
70
|
+
onApply({ fit, rect });
|
|
67
71
|
};
|
|
68
72
|
|
|
69
73
|
return (
|
|
@@ -98,7 +102,6 @@ export function ImageCropDialog({
|
|
|
98
102
|
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
|
99
103
|
aspect={aspect}
|
|
100
104
|
keepSelection
|
|
101
|
-
locked
|
|
102
105
|
className="max-h-full"
|
|
103
106
|
>
|
|
104
107
|
<img
|
|
@@ -124,6 +127,19 @@ export function ImageCropDialog({
|
|
|
124
127
|
);
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
function initialCrop(
|
|
131
|
+
naturalW: number,
|
|
132
|
+
naturalH: number,
|
|
133
|
+
aspect: number,
|
|
134
|
+
rect: ImageCropRect | null,
|
|
135
|
+
position: { x: number; y: number },
|
|
136
|
+
): PercentCrop {
|
|
137
|
+
if (rect) {
|
|
138
|
+
return clampToAspect({ unit: '%', ...rect }, aspect, naturalW, naturalH);
|
|
139
|
+
}
|
|
140
|
+
return makeMaxSizeCrop(naturalW, naturalH, aspect, position);
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
function makeMaxSizeCrop(
|
|
128
144
|
naturalW: number,
|
|
129
145
|
naturalH: number,
|
|
@@ -150,12 +166,40 @@ function makeMaxSizeCrop(
|
|
|
150
166
|
return { unit: '%', x, y, width, height };
|
|
151
167
|
}
|
|
152
168
|
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
function clampToAspect(
|
|
170
|
+
crop: PercentCrop,
|
|
171
|
+
aspect: number,
|
|
172
|
+
naturalW: number,
|
|
173
|
+
naturalH: number,
|
|
174
|
+
): PercentCrop {
|
|
175
|
+
const cx = crop.x + crop.width / 2;
|
|
176
|
+
const cy = crop.y + crop.height / 2;
|
|
177
|
+
let width = crop.width;
|
|
178
|
+
let height = crop.height;
|
|
179
|
+
const targetPctRatio = naturalW > 0 && naturalH > 0 ? (aspect * naturalH) / naturalW : aspect;
|
|
180
|
+
const currentRatio = height > 0 ? width / height : targetPctRatio;
|
|
181
|
+
if (Math.abs(currentRatio - targetPctRatio) > 0.0001) {
|
|
182
|
+
if (currentRatio > targetPctRatio) {
|
|
183
|
+
height = width / targetPctRatio;
|
|
184
|
+
} else {
|
|
185
|
+
width = height * targetPctRatio;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
width = clamp(width, 1, 100);
|
|
189
|
+
height = clamp(height, 1, 100);
|
|
190
|
+
let x = cx - width / 2;
|
|
191
|
+
let y = cy - height / 2;
|
|
192
|
+
x = clamp(x, 0, 100 - width);
|
|
193
|
+
y = clamp(y, 0, 100 - height);
|
|
194
|
+
return { unit: '%', x, y, width, height };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function roundRect(crop: PercentCrop): ImageCropRect {
|
|
156
198
|
return {
|
|
157
|
-
x:
|
|
158
|
-
y:
|
|
199
|
+
x: round2(crop.x),
|
|
200
|
+
y: round2(crop.y),
|
|
201
|
+
width: round2(crop.width),
|
|
202
|
+
height: round2(crop.height),
|
|
159
203
|
};
|
|
160
204
|
}
|
|
161
205
|
|
|
@@ -3,12 +3,17 @@ import {
|
|
|
3
3
|
AlignJustify,
|
|
4
4
|
AlignLeft,
|
|
5
5
|
AlignRight,
|
|
6
|
+
ArrowDownToLine,
|
|
6
7
|
Bold,
|
|
8
|
+
Crop,
|
|
7
9
|
ImageIcon,
|
|
8
10
|
Italic,
|
|
11
|
+
Loader2,
|
|
12
|
+
Upload,
|
|
9
13
|
X,
|
|
10
14
|
} from 'lucide-react';
|
|
11
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
16
|
+
import { toast } from 'sonner';
|
|
12
17
|
import { Field, NumberField, Section } from '@/components/panel/panel-fields';
|
|
13
18
|
import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
|
|
14
19
|
import { Button } from '@/components/ui/button';
|
|
@@ -32,10 +37,12 @@ import { Slider } from '@/components/ui/slider';
|
|
|
32
37
|
import { Textarea } from '@/components/ui/textarea';
|
|
33
38
|
import { Toggle } from '@/components/ui/toggle';
|
|
34
39
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
35
|
-
import {
|
|
40
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
41
|
+
import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
|
|
36
42
|
import { findSlideSource } from '@/lib/inspector/fiber';
|
|
37
43
|
import type { EditOp } from '@/lib/inspector/use-editor';
|
|
38
|
-
import {
|
|
44
|
+
import { useAgentSocketConnected } from '@/lib/use-agent-socket';
|
|
45
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
39
46
|
import { cn } from '@/lib/utils';
|
|
40
47
|
import type { Locale } from '../../../locale/types';
|
|
41
48
|
import { type SelectedTarget, useInspector } from './inspector-provider';
|
|
@@ -149,15 +156,18 @@ export function InspectorPanel() {
|
|
|
149
156
|
<{pinSelected.anchor.tagName.toLowerCase()}>
|
|
150
157
|
</span>
|
|
151
158
|
</div>
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
<div className="flex items-center gap-1.5">
|
|
160
|
+
<AgentWatchingBadge />
|
|
161
|
+
<Button
|
|
162
|
+
variant="ghost"
|
|
163
|
+
size="icon-sm"
|
|
164
|
+
className="text-muted-foreground hover:text-foreground"
|
|
165
|
+
onClick={() => setSelected(null)}
|
|
166
|
+
aria-label={t.inspector.deselect}
|
|
167
|
+
>
|
|
168
|
+
<X className="size-3.5" />
|
|
169
|
+
</Button>
|
|
170
|
+
</div>
|
|
161
171
|
</>
|
|
162
172
|
}
|
|
163
173
|
footer={<CommentsSection selected={pinSelected} onAdd={add} />}
|
|
@@ -637,6 +647,7 @@ function ImageField({
|
|
|
637
647
|
className="flex-1"
|
|
638
648
|
onClick={() => openCrop(anchor as HTMLImageElement)}
|
|
639
649
|
>
|
|
650
|
+
<Crop className="size-3.5" />
|
|
640
651
|
{t.inspector.crop}
|
|
641
652
|
</Button>
|
|
642
653
|
)}
|
|
@@ -740,11 +751,35 @@ function AssetPickerDialog({
|
|
|
740
751
|
onClose: () => void;
|
|
741
752
|
onPick: (asset: AssetEntry) => void;
|
|
742
753
|
}) {
|
|
743
|
-
const { assets, loading } = useAssets(slideId);
|
|
754
|
+
const { assets, loading, refresh } = useAssets(slideId);
|
|
744
755
|
const images = assets.filter((a) => a.mime.startsWith('image/'));
|
|
745
756
|
const t = useLocale();
|
|
746
757
|
const path = `slides/${slideId}/assets/`;
|
|
747
758
|
const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
|
|
759
|
+
const [uploading, setUploading] = useState(false);
|
|
760
|
+
const [dragActive, setDragActive] = useState(false);
|
|
761
|
+
const dragDepth = useRef(0);
|
|
762
|
+
const inputId = useId();
|
|
763
|
+
|
|
764
|
+
const handleFile = useCallback(
|
|
765
|
+
async (file: File) => {
|
|
766
|
+
if (!file.type.startsWith('image/')) return;
|
|
767
|
+
setUploading(true);
|
|
768
|
+
try {
|
|
769
|
+
const { ok, status, entry } = await uploadWithAutoRename(slideId, file);
|
|
770
|
+
if (!ok || !entry) {
|
|
771
|
+
toast.error(format(t.asset.toastUploadFailed, { status }));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
await refresh().catch(() => {});
|
|
775
|
+
onPick(entry);
|
|
776
|
+
} finally {
|
|
777
|
+
setUploading(false);
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
[slideId, refresh, onPick, t],
|
|
781
|
+
);
|
|
782
|
+
|
|
748
783
|
return (
|
|
749
784
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
750
785
|
<DialogContent className="sm:max-w-xl">
|
|
@@ -756,7 +791,60 @@ function AssetPickerDialog({
|
|
|
756
791
|
{descSuffix}
|
|
757
792
|
</DialogDescription>
|
|
758
793
|
</DialogHeader>
|
|
759
|
-
<
|
|
794
|
+
<label
|
|
795
|
+
htmlFor={inputId}
|
|
796
|
+
className={cn(
|
|
797
|
+
'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',
|
|
798
|
+
'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
|
|
799
|
+
uploading && 'pointer-events-none opacity-60',
|
|
800
|
+
)}
|
|
801
|
+
>
|
|
802
|
+
{uploading ? (
|
|
803
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
804
|
+
) : (
|
|
805
|
+
<Upload className="size-3.5" />
|
|
806
|
+
)}
|
|
807
|
+
<span>{t.asset.upload}</span>
|
|
808
|
+
</label>
|
|
809
|
+
<input
|
|
810
|
+
id={inputId}
|
|
811
|
+
type="file"
|
|
812
|
+
accept="image/*"
|
|
813
|
+
className="sr-only"
|
|
814
|
+
disabled={uploading}
|
|
815
|
+
onChange={(e) => {
|
|
816
|
+
const file = e.target.files?.[0];
|
|
817
|
+
e.target.value = '';
|
|
818
|
+
if (file) handleFile(file).catch(() => {});
|
|
819
|
+
}}
|
|
820
|
+
/>
|
|
821
|
+
<section
|
|
822
|
+
aria-label={t.inspector.replaceImageDialogTitle}
|
|
823
|
+
className="relative max-h-[60vh] overflow-y-auto"
|
|
824
|
+
onDragEnter={(e) => {
|
|
825
|
+
if (uploading || !hasFiles(e)) return;
|
|
826
|
+
e.preventDefault();
|
|
827
|
+
dragDepth.current += 1;
|
|
828
|
+
setDragActive(true);
|
|
829
|
+
}}
|
|
830
|
+
onDragOver={(e) => {
|
|
831
|
+
if (uploading || !hasFiles(e)) return;
|
|
832
|
+
e.preventDefault();
|
|
833
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
834
|
+
}}
|
|
835
|
+
onDragLeave={() => {
|
|
836
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
837
|
+
if (dragDepth.current === 0) setDragActive(false);
|
|
838
|
+
}}
|
|
839
|
+
onDrop={(e) => {
|
|
840
|
+
if (uploading || !hasFiles(e)) return;
|
|
841
|
+
e.preventDefault();
|
|
842
|
+
dragDepth.current = 0;
|
|
843
|
+
setDragActive(false);
|
|
844
|
+
const file = e.dataTransfer.files?.[0];
|
|
845
|
+
if (file) handleFile(file).catch(() => {});
|
|
846
|
+
}}
|
|
847
|
+
>
|
|
760
848
|
{loading ? (
|
|
761
849
|
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
|
762
850
|
{t.inspector.pickerLoading}
|
|
@@ -794,12 +882,68 @@ function AssetPickerDialog({
|
|
|
794
882
|
))}
|
|
795
883
|
</div>
|
|
796
884
|
)}
|
|
797
|
-
|
|
885
|
+
{dragActive && (
|
|
886
|
+
<div
|
|
887
|
+
className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
|
|
888
|
+
aria-hidden
|
|
889
|
+
>
|
|
890
|
+
<div className="absolute inset-0 bg-brand/5" />
|
|
891
|
+
<div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
|
|
892
|
+
<div className="absolute inset-x-0 bottom-4 flex justify-center">
|
|
893
|
+
<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">
|
|
894
|
+
<ArrowDownToLine className="size-3.5 text-brand" />
|
|
895
|
+
<span>{t.asset.dropToUpload}</span>
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
898
|
+
</div>
|
|
899
|
+
)}
|
|
900
|
+
</section>
|
|
798
901
|
</DialogContent>
|
|
799
902
|
</Dialog>
|
|
800
903
|
);
|
|
801
904
|
}
|
|
802
905
|
|
|
906
|
+
function hasFiles(e: React.DragEvent): boolean {
|
|
907
|
+
const types = e.dataTransfer?.types;
|
|
908
|
+
if (!types) return false;
|
|
909
|
+
for (let i = 0; i < types.length; i++) {
|
|
910
|
+
if (types[i] === 'Files') return true;
|
|
911
|
+
}
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function AgentWatchingBadge() {
|
|
916
|
+
const t = useLocale();
|
|
917
|
+
const connected = useAgentSocketConnected();
|
|
918
|
+
return (
|
|
919
|
+
<TooltipProvider delayDuration={200}>
|
|
920
|
+
<Tooltip>
|
|
921
|
+
<TooltipTrigger asChild>
|
|
922
|
+
<button
|
|
923
|
+
type="button"
|
|
924
|
+
className="flex shrink-0 cursor-help items-center gap-1.5 rounded-[3px] border border-hairline bg-card px-1.5 py-px text-[10.5px] text-foreground/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
925
|
+
>
|
|
926
|
+
<span aria-hidden className="relative flex size-1.5 items-center justify-center">
|
|
927
|
+
{connected ? (
|
|
928
|
+
<>
|
|
929
|
+
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
|
930
|
+
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
|
|
931
|
+
</>
|
|
932
|
+
) : (
|
|
933
|
+
<span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
|
|
934
|
+
)}
|
|
935
|
+
</span>
|
|
936
|
+
{connected ? t.inspector.agentWatching : t.inspector.agentNotWatching}
|
|
937
|
+
</button>
|
|
938
|
+
</TooltipTrigger>
|
|
939
|
+
<TooltipContent side="bottom" align="end" className="max-w-[260px] leading-relaxed">
|
|
940
|
+
{connected ? t.inspector.agentWatchingTooltip : t.inspector.agentNotWatchingTooltip}
|
|
941
|
+
</TooltipContent>
|
|
942
|
+
</Tooltip>
|
|
943
|
+
</TooltipProvider>
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
803
947
|
function CommentsSection({
|
|
804
948
|
selected,
|
|
805
949
|
onAdd,
|
|
@@ -824,7 +968,7 @@ function CommentsSection({
|
|
|
824
968
|
};
|
|
825
969
|
|
|
826
970
|
return (
|
|
827
|
-
<Section title={t.inspector.
|
|
971
|
+
<Section title={t.inspector.leaveComment}>
|
|
828
972
|
<div className="flex flex-col gap-2">
|
|
829
973
|
<div className="comment-cue rounded-[6px]">
|
|
830
974
|
<Textarea
|
|
@@ -836,16 +980,16 @@ function CommentsSection({
|
|
|
836
980
|
submit();
|
|
837
981
|
}
|
|
838
982
|
}}
|
|
839
|
-
placeholder={t.inspector.
|
|
983
|
+
placeholder={t.inspector.commentPlaceholder}
|
|
840
984
|
className="min-h-16 resize-none text-[12px]"
|
|
841
985
|
/>
|
|
842
986
|
</div>
|
|
843
987
|
<div className="flex items-center justify-between gap-2">
|
|
844
988
|
<span className="font-mono text-[10.5px] text-muted-foreground/70">
|
|
845
|
-
{t.inspector.
|
|
989
|
+
{t.inspector.commentShortcutHint}
|
|
846
990
|
</span>
|
|
847
991
|
<Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
|
|
848
|
-
{t.inspector.
|
|
992
|
+
{t.inspector.addComment}
|
|
849
993
|
</Button>
|
|
850
994
|
</div>
|
|
851
995
|
</div>
|
|
@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button';
|
|
|
15
15
|
import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
|
|
16
16
|
import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
|
|
17
17
|
import { useLocale } from '@/lib/use-locale';
|
|
18
|
-
import { ImageCropDialog } from './image-crop-dialog';
|
|
18
|
+
import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog';
|
|
19
19
|
|
|
20
20
|
export type SelectedTarget = {
|
|
21
21
|
line: number;
|
|
@@ -101,6 +101,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
101
101
|
targetHeight: number;
|
|
102
102
|
initialFit: 'cover' | 'contain';
|
|
103
103
|
initialPosition: { x: number; y: number };
|
|
104
|
+
initialRect: ImageCropRect | null;
|
|
104
105
|
} | null>(null);
|
|
105
106
|
const t = useLocale();
|
|
106
107
|
|
|
@@ -558,6 +559,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
558
559
|
targetHeight: anchor.offsetHeight || anchor.getBoundingClientRect().height,
|
|
559
560
|
initialFit: cs.objectFit === 'contain' ? 'contain' : 'cover',
|
|
560
561
|
initialPosition: parseObjectPosition(cs.objectPosition),
|
|
562
|
+
initialRect: parseObjectViewBox(cs.getPropertyValue('object-view-box')),
|
|
561
563
|
});
|
|
562
564
|
}, []);
|
|
563
565
|
|
|
@@ -615,18 +617,30 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
615
617
|
targetHeight={cropTarget.targetHeight}
|
|
616
618
|
initialFit={cropTarget.initialFit}
|
|
617
619
|
initialPosition={cropTarget.initialPosition}
|
|
620
|
+
initialRect={cropTarget.initialRect}
|
|
618
621
|
onClose={() => setCropTarget(null)}
|
|
619
622
|
onApply={(result) => {
|
|
620
623
|
const { line, column, anchor } = cropTarget;
|
|
621
624
|
if (anchor.isConnected) {
|
|
622
|
-
|
|
625
|
+
const ops: EditOp[] = [
|
|
623
626
|
{ kind: 'set-style', key: 'objectFit', value: result.fit },
|
|
624
|
-
{
|
|
627
|
+
{ kind: 'set-style', key: 'objectPosition', value: '50% 50%' },
|
|
628
|
+
];
|
|
629
|
+
if (result.fit === 'cover') {
|
|
630
|
+
const { x, y, width, height } = result.rect;
|
|
631
|
+
const top = round2(y);
|
|
632
|
+
const left = round2(x);
|
|
633
|
+
const right = round2(100 - x - width);
|
|
634
|
+
const bottom = round2(100 - y - height);
|
|
635
|
+
ops.push({
|
|
625
636
|
kind: 'set-style',
|
|
626
|
-
key: '
|
|
627
|
-
value:
|
|
628
|
-
}
|
|
629
|
-
|
|
637
|
+
key: 'objectViewBox',
|
|
638
|
+
value: `inset(${top}% ${right}% ${bottom}% ${left}%)`,
|
|
639
|
+
});
|
|
640
|
+
} else {
|
|
641
|
+
ops.push({ kind: 'set-style', key: 'objectViewBox', value: null });
|
|
642
|
+
}
|
|
643
|
+
bufferOps(line, column, anchor, ops);
|
|
630
644
|
}
|
|
631
645
|
setCropTarget(null);
|
|
632
646
|
}}
|
|
@@ -640,6 +654,45 @@ function round2(n: number): number {
|
|
|
640
654
|
return Math.round(n * 100) / 100;
|
|
641
655
|
}
|
|
642
656
|
|
|
657
|
+
function parseObjectViewBox(value: string): ImageCropRect | null {
|
|
658
|
+
const v = value?.trim();
|
|
659
|
+
if (!v || v === 'none') return null;
|
|
660
|
+
const m = v.match(/^inset\(([^)]+)\)$/);
|
|
661
|
+
if (!m?.[1]) return null;
|
|
662
|
+
const nums = m[1]
|
|
663
|
+
.trim()
|
|
664
|
+
.split(/\s+/)
|
|
665
|
+
.map((p) => {
|
|
666
|
+
const n = p.match(/^(-?\d+(?:\.\d+)?)%$/);
|
|
667
|
+
return n?.[1] ? Number(n[1]) : null;
|
|
668
|
+
});
|
|
669
|
+
if (nums.some((n) => n === null)) return null;
|
|
670
|
+
let top: number, right: number, bottom: number, left: number;
|
|
671
|
+
if (nums.length === 1) {
|
|
672
|
+
top = right = bottom = left = nums[0] as number;
|
|
673
|
+
} else if (nums.length === 2) {
|
|
674
|
+
top = bottom = nums[0] as number;
|
|
675
|
+
right = left = nums[1] as number;
|
|
676
|
+
} else if (nums.length === 3) {
|
|
677
|
+
top = nums[0] as number;
|
|
678
|
+
right = left = nums[1] as number;
|
|
679
|
+
bottom = nums[2] as number;
|
|
680
|
+
} else if (nums.length === 4) {
|
|
681
|
+
top = nums[0] as number;
|
|
682
|
+
right = nums[1] as number;
|
|
683
|
+
bottom = nums[2] as number;
|
|
684
|
+
left = nums[3] as number;
|
|
685
|
+
} else {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
const x = left;
|
|
689
|
+
const y = top;
|
|
690
|
+
const width = 100 - left - right;
|
|
691
|
+
const height = 100 - top - bottom;
|
|
692
|
+
if (width <= 0 || height <= 0) return null;
|
|
693
|
+
return { x, y, width, height };
|
|
694
|
+
}
|
|
695
|
+
|
|
643
696
|
function parseObjectPosition(value: string): { x: number; y: number } {
|
|
644
697
|
const parts = value.trim().split(/\s+/);
|
|
645
698
|
const xRaw = parts[0] ?? '50%';
|