@open-slide/core 1.5.0 → 1.7.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-DZhbjQpQ.js → build-tLrkKUHr.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BQdTMho4.d.ts → config-CfMThYN9.d.ts} +1 -1
- package/dist/{config-iKjqaX08.js → config-PwUHqZ_X.js} +246 -2
- package/dist/{dev-BjLGk5nN.js → dev-DpCIRbhT.js} +1 -1
- package/dist/{en-DDGqyNaW.js → en-BDnM5zKJ.js} +4 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +20 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +13 -1
- package/dist/{preview-jwLWHWkQ.js → preview-BSGlM6Se.js} +1 -1
- package/dist/{types-Dpr8nbih.d.ts → types-B-KrjgX8.d.ts} +5 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +30 -22
- package/skills/slide-authoring/SKILL.md +186 -0
- package/src/app/components/asset-view.tsx +8 -1
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/inspect-overlay.tsx +132 -35
- package/src/app/components/inspector/inspector-panel.tsx +19 -256
- package/src/app/components/inspector/inspector-provider.tsx +102 -1
- package/src/app/components/panel/save-card.tsx +4 -4
- package/src/app/components/player.tsx +13 -3
- package/src/app/components/present/overview-grid.tsx +4 -1
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -0
- package/src/app/components/themes/theme-detail.tsx +7 -2
- package/src/app/components/themes/themes-gallery.tsx +4 -1
- package/src/app/components/thumbnail-rail.tsx +10 -2
- package/src/app/lib/assets.ts +2 -0
- package/src/app/lib/export-html.ts +7 -2
- package/src/app/lib/export-pdf.ts +34 -2
- package/src/app/lib/folders.ts +35 -1
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/sdk.ts +3 -1
- package/src/app/lib/transition.ts +23 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-wheel-page-navigation.ts +7 -0
- package/src/app/routes/home-shell.tsx +13 -2
- package/src/app/routes/home.tsx +28 -2
- package/src/app/routes/presenter.tsx +7 -2
- package/src/app/routes/slide.tsx +19 -8
- package/src/locale/en.ts +4 -0
- package/src/locale/ja.ts +4 -0
- package/src/locale/types.ts +5 -0
- package/src/locale/zh-cn.ts +4 -0
- package/src/locale/zh-tw.ts +4 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { Crop, ImageIcon } from 'lucide-react';
|
|
1
2
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
3
|
import { PANEL_TRANSITION_MS } from '@/components/panel/panel-shell';
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
3
5
|
import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber';
|
|
6
|
+
import { useLocale } from '@/lib/use-locale';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
4
8
|
import { useInspector } from './inspector-provider';
|
|
5
9
|
|
|
6
10
|
type Highlight = { hit: SlideSourceHit };
|
|
@@ -31,6 +35,9 @@ export function InspectOverlay() {
|
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
const onMove = (e: PointerEvent) => {
|
|
38
|
+
if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) {
|
|
39
|
+
return setHover(null);
|
|
40
|
+
}
|
|
34
41
|
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
|
|
35
42
|
if (!el) return setHover(null);
|
|
36
43
|
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
@@ -75,37 +82,48 @@ export function InspectOverlay() {
|
|
|
75
82
|
};
|
|
76
83
|
}, [active, slideId, setSelected, cancel, openCrop]);
|
|
77
84
|
|
|
85
|
+
const hoverAnchor = hover?.hit.anchor.isConnected ? hover.hit.anchor : null;
|
|
86
|
+
const selectedAnchor = selected?.anchor.isConnected ? selected.anchor : null;
|
|
87
|
+
const dedupedHover = hoverAnchor && hoverAnchor !== selectedAnchor ? hoverAnchor : null;
|
|
88
|
+
|
|
89
|
+
if (!active) return null;
|
|
78
90
|
return (
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
overlayRef={overlayRef}
|
|
82
|
-
|
|
83
|
-
// is editing even after the cursor moves away.
|
|
84
|
-
targetAnchor={selected?.anchor ?? hover?.hit.anchor ?? null}
|
|
85
|
-
/>
|
|
91
|
+
<div ref={overlayRef} data-inspector-ui className="pointer-events-none absolute inset-0 z-30">
|
|
92
|
+
<Frame anchor={selectedAnchor} overlayRef={overlayRef} variant="selected" showImageActions />
|
|
93
|
+
<Frame anchor={dedupedHover} overlayRef={overlayRef} variant="hover" />
|
|
94
|
+
</div>
|
|
86
95
|
);
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
type FrameVariant = 'selected' | 'hover';
|
|
99
|
+
|
|
100
|
+
const FRAME_STYLES: Record<FrameVariant, React.CSSProperties> = {
|
|
101
|
+
selected: { outline: '2px solid #3b82f6', background: 'rgba(59,130,246,0.1)' },
|
|
102
|
+
hover: { outline: '1.5px dashed #3b82f6', background: 'rgba(59,130,246,0.05)' },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function Frame({
|
|
106
|
+
anchor,
|
|
91
107
|
overlayRef,
|
|
92
|
-
|
|
108
|
+
variant,
|
|
109
|
+
showImageActions = false,
|
|
93
110
|
}: {
|
|
94
|
-
|
|
111
|
+
anchor: HTMLElement | null;
|
|
95
112
|
overlayRef: React.RefObject<HTMLDivElement>;
|
|
96
|
-
|
|
113
|
+
variant: FrameVariant;
|
|
114
|
+
showImageActions?: boolean;
|
|
97
115
|
}) {
|
|
98
116
|
const [rect, setRect] = useState<RelRect | null>(null);
|
|
99
117
|
const [hasTarget, setHasTarget] = useState(false);
|
|
100
118
|
|
|
101
119
|
const measure = useCallback(() => {
|
|
102
120
|
const overlay = overlayRef.current;
|
|
103
|
-
if (!
|
|
121
|
+
if (!anchor?.isConnected || !overlay) {
|
|
104
122
|
setHasTarget(false);
|
|
105
123
|
return;
|
|
106
124
|
}
|
|
107
125
|
|
|
108
|
-
const targetRect =
|
|
126
|
+
const targetRect = anchor.getBoundingClientRect();
|
|
109
127
|
const overlayRect = overlay.getBoundingClientRect();
|
|
110
128
|
const next = {
|
|
111
129
|
left: targetRect.left - overlayRect.left,
|
|
@@ -116,14 +134,14 @@ function FrameOverlay({
|
|
|
116
134
|
|
|
117
135
|
setHasTarget(true);
|
|
118
136
|
setRect((prev) => (sameRect(prev, next) ? prev : next));
|
|
119
|
-
}, [
|
|
137
|
+
}, [overlayRef, anchor]);
|
|
120
138
|
|
|
121
139
|
useLayoutEffect(() => {
|
|
122
140
|
measure();
|
|
123
141
|
}, [measure]);
|
|
124
142
|
|
|
125
143
|
useEffect(() => {
|
|
126
|
-
if (!
|
|
144
|
+
if (!anchor) {
|
|
127
145
|
setHasTarget(false);
|
|
128
146
|
return;
|
|
129
147
|
}
|
|
@@ -139,7 +157,7 @@ function FrameOverlay({
|
|
|
139
157
|
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
140
158
|
if (root) resizeObserver.observe(root);
|
|
141
159
|
if (overlayRef.current) resizeObserver.observe(overlayRef.current);
|
|
142
|
-
|
|
160
|
+
resizeObserver.observe(anchor);
|
|
143
161
|
|
|
144
162
|
const stopAt = performance.now() + LAYOUT_TRACK_MS;
|
|
145
163
|
const trackPanelTransition = () => {
|
|
@@ -157,9 +175,9 @@ function FrameOverlay({
|
|
|
157
175
|
window.removeEventListener('resize', scheduleMeasure, true);
|
|
158
176
|
window.removeEventListener('scroll', scheduleMeasure, true);
|
|
159
177
|
};
|
|
160
|
-
}, [
|
|
178
|
+
}, [measure, overlayRef, anchor]);
|
|
161
179
|
|
|
162
|
-
const visible = !!(
|
|
180
|
+
const visible = !!(hasTarget && rect);
|
|
163
181
|
|
|
164
182
|
// First render after appearing: snap to the new rect (no transition).
|
|
165
183
|
// Subsequent rect changes in the same visible session: animate.
|
|
@@ -173,31 +191,110 @@ function FrameOverlay({
|
|
|
173
191
|
return () => clearTimeout(t);
|
|
174
192
|
}, [visible]);
|
|
175
193
|
|
|
176
|
-
if (!
|
|
194
|
+
if (!rect) return null;
|
|
177
195
|
const transition = morph
|
|
178
196
|
? `left ${FRAME_MORPH_MS}ms ease-out, top ${FRAME_MORPH_MS}ms ease-out, ` +
|
|
179
197
|
`width ${FRAME_MORPH_MS}ms ease-out, height ${FRAME_MORPH_MS}ms ease-out, ` +
|
|
180
198
|
`opacity ${FRAME_FADE_MS}ms ease-out`
|
|
181
199
|
: `opacity ${FRAME_FADE_MS}ms ease-out`;
|
|
182
200
|
|
|
201
|
+
const imageAnchor = anchor instanceof HTMLImageElement ? anchor : null;
|
|
202
|
+
const actionsVisible = showImageActions && visible && !!imageAnchor;
|
|
203
|
+
|
|
183
204
|
return (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
<>
|
|
206
|
+
<div
|
|
207
|
+
className="absolute"
|
|
208
|
+
style={{
|
|
209
|
+
left: rect.left,
|
|
210
|
+
top: rect.top,
|
|
211
|
+
width: rect.width,
|
|
212
|
+
height: rect.height,
|
|
213
|
+
opacity: visible ? 1 : 0,
|
|
214
|
+
transition,
|
|
215
|
+
...FRAME_STYLES[variant],
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
{showImageActions && imageAnchor && (
|
|
219
|
+
<ImageActionPanel
|
|
220
|
+
anchor={imageAnchor}
|
|
221
|
+
rect={rect}
|
|
222
|
+
visible={actionsVisible}
|
|
223
|
+
transition={transition}
|
|
198
224
|
/>
|
|
199
225
|
)}
|
|
200
|
-
|
|
226
|
+
</>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const FLOATING_PANEL_GAP = 8;
|
|
231
|
+
|
|
232
|
+
function ImageActionPanel({
|
|
233
|
+
anchor,
|
|
234
|
+
rect,
|
|
235
|
+
visible,
|
|
236
|
+
transition,
|
|
237
|
+
}: {
|
|
238
|
+
anchor: HTMLElement;
|
|
239
|
+
rect: RelRect;
|
|
240
|
+
visible: boolean;
|
|
241
|
+
transition: string;
|
|
242
|
+
}) {
|
|
243
|
+
const { openCrop, openReplace } = useInspector();
|
|
244
|
+
const t = useLocale();
|
|
245
|
+
return (
|
|
246
|
+
<TooltipProvider delayDuration={200}>
|
|
247
|
+
<div
|
|
248
|
+
className={cn(
|
|
249
|
+
'absolute flex items-center gap-0.5 rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
|
|
250
|
+
visible ? 'pointer-events-auto' : 'pointer-events-none',
|
|
251
|
+
)}
|
|
252
|
+
style={{
|
|
253
|
+
left: rect.left + rect.width / 2,
|
|
254
|
+
top: rect.top + rect.height + FLOATING_PANEL_GAP,
|
|
255
|
+
transform: 'translateX(-50%)',
|
|
256
|
+
opacity: visible ? 1 : 0,
|
|
257
|
+
transition,
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<Tooltip>
|
|
261
|
+
<TooltipTrigger asChild>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
aria-label={t.inspector.replace}
|
|
265
|
+
onClick={(e) => {
|
|
266
|
+
e.stopPropagation();
|
|
267
|
+
openReplace(anchor);
|
|
268
|
+
}}
|
|
269
|
+
className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
270
|
+
>
|
|
271
|
+
<ImageIcon className="size-3.5" />
|
|
272
|
+
</button>
|
|
273
|
+
</TooltipTrigger>
|
|
274
|
+
<TooltipContent side="bottom" data-inspector-ui>
|
|
275
|
+
{t.inspector.replace}
|
|
276
|
+
</TooltipContent>
|
|
277
|
+
</Tooltip>
|
|
278
|
+
<Tooltip>
|
|
279
|
+
<TooltipTrigger asChild>
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
aria-label={t.inspector.crop}
|
|
283
|
+
onClick={(e) => {
|
|
284
|
+
e.stopPropagation();
|
|
285
|
+
openCrop(anchor as HTMLImageElement);
|
|
286
|
+
}}
|
|
287
|
+
className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
288
|
+
>
|
|
289
|
+
<Crop className="size-3.5" />
|
|
290
|
+
</button>
|
|
291
|
+
</TooltipTrigger>
|
|
292
|
+
<TooltipContent side="bottom" data-inspector-ui>
|
|
293
|
+
{t.inspector.crop}
|
|
294
|
+
</TooltipContent>
|
|
295
|
+
</Tooltip>
|
|
296
|
+
</div>
|
|
297
|
+
</TooltipProvider>
|
|
201
298
|
);
|
|
202
299
|
}
|
|
203
300
|
|
|
@@ -3,27 +3,17 @@ import {
|
|
|
3
3
|
AlignJustify,
|
|
4
4
|
AlignLeft,
|
|
5
5
|
AlignRight,
|
|
6
|
-
ArrowDownToLine,
|
|
7
6
|
Bold,
|
|
8
7
|
Crop,
|
|
8
|
+
Crosshair,
|
|
9
9
|
ImageIcon,
|
|
10
10
|
Italic,
|
|
11
|
-
Loader2,
|
|
12
|
-
Upload,
|
|
13
11
|
X,
|
|
14
12
|
} from 'lucide-react';
|
|
15
|
-
import { useCallback, useEffect,
|
|
16
|
-
import { toast } from 'sonner';
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
14
|
import { Field, NumberField, Section } from '@/components/panel/panel-fields';
|
|
18
15
|
import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
|
|
19
16
|
import { Button } from '@/components/ui/button';
|
|
20
|
-
import {
|
|
21
|
-
Dialog,
|
|
22
|
-
DialogContent,
|
|
23
|
-
DialogDescription,
|
|
24
|
-
DialogHeader,
|
|
25
|
-
DialogTitle,
|
|
26
|
-
} from '@/components/ui/dialog';
|
|
27
17
|
import { Input } from '@/components/ui/input';
|
|
28
18
|
import {
|
|
29
19
|
Select,
|
|
@@ -34,18 +24,16 @@ import {
|
|
|
34
24
|
} from '@/components/ui/select';
|
|
35
25
|
import { Separator } from '@/components/ui/separator';
|
|
36
26
|
import { Slider } from '@/components/ui/slider';
|
|
37
|
-
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
38
27
|
import { Textarea } from '@/components/ui/textarea';
|
|
39
28
|
import { Toggle } from '@/components/ui/toggle';
|
|
40
29
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
41
30
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
42
|
-
import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
|
|
43
31
|
import { findSlideSource } from '@/lib/inspector/fiber';
|
|
44
32
|
import type { EditOp } from '@/lib/inspector/use-editor';
|
|
45
33
|
import { useAgentSocketConnected } from '@/lib/use-agent-socket';
|
|
46
|
-
import {
|
|
47
|
-
import { cn } from '@/lib/utils';
|
|
34
|
+
import { useLocale } from '@/lib/use-locale';
|
|
48
35
|
import type { Locale } from '../../../locale/types';
|
|
36
|
+
import { AssetPickerDialog } from './asset-picker-dialog';
|
|
49
37
|
import { type SelectedTarget, useInspector } from './inspector-provider';
|
|
50
38
|
|
|
51
39
|
type ElementSnapshot = {
|
|
@@ -249,6 +237,7 @@ export function InspectorPanel() {
|
|
|
249
237
|
header={
|
|
250
238
|
<>
|
|
251
239
|
<div className="flex min-w-0 items-center gap-2">
|
|
240
|
+
<Crosshair className="size-3.5 text-muted-foreground" />
|
|
252
241
|
<span className="font-heading text-[12px] font-semibold tracking-tight">
|
|
253
242
|
{t.inspector.inspect}
|
|
254
243
|
</span>
|
|
@@ -274,17 +263,18 @@ export function InspectorPanel() {
|
|
|
274
263
|
footer={<CommentsSection selected={pinSelected} onAdd={add} />}
|
|
275
264
|
>
|
|
276
265
|
{pinSnapshot.text !== null && (
|
|
277
|
-
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
266
|
+
<>
|
|
267
|
+
<Section title={t.inspector.contentSection}>
|
|
268
|
+
<ContentField
|
|
269
|
+
snapshot={pinSnapshot}
|
|
270
|
+
apply={apply}
|
|
271
|
+
onSelectionChange={setContentSelection}
|
|
272
|
+
/>
|
|
273
|
+
</Section>
|
|
274
|
+
<Separator />
|
|
275
|
+
</>
|
|
284
276
|
)}
|
|
285
277
|
|
|
286
|
-
<Separator />
|
|
287
|
-
|
|
288
278
|
<Section title={t.inspector.typographySection}>
|
|
289
279
|
<FontSizeField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
290
280
|
<FontWeightField snapshot={typographySnapshot} apply={applyTextStyle} />
|
|
@@ -317,12 +307,7 @@ export function InspectorPanel() {
|
|
|
317
307
|
<>
|
|
318
308
|
<Separator />
|
|
319
309
|
<Section title={t.inspector.imageSection}>
|
|
320
|
-
<ImageField
|
|
321
|
-
slideId={slideId}
|
|
322
|
-
src={pinSnapshot.imageSrc}
|
|
323
|
-
anchor={pinSelected.anchor}
|
|
324
|
-
apply={apply}
|
|
325
|
-
/>
|
|
310
|
+
<ImageField src={pinSnapshot.imageSrc} anchor={pinSelected.anchor} />
|
|
326
311
|
</Section>
|
|
327
312
|
</>
|
|
328
313
|
)}
|
|
@@ -747,20 +732,9 @@ function ColorField({
|
|
|
747
732
|
);
|
|
748
733
|
}
|
|
749
734
|
|
|
750
|
-
function ImageField({
|
|
751
|
-
slideId,
|
|
752
|
-
src,
|
|
753
|
-
anchor,
|
|
754
|
-
apply,
|
|
755
|
-
}: {
|
|
756
|
-
slideId: string;
|
|
757
|
-
src: string;
|
|
758
|
-
anchor: HTMLElement;
|
|
759
|
-
apply: (ops: EditOp[]) => void;
|
|
760
|
-
}) {
|
|
761
|
-
const [open, setOpen] = useState(false);
|
|
735
|
+
function ImageField({ src, anchor }: { src: string; anchor: HTMLElement }) {
|
|
762
736
|
const t = useLocale();
|
|
763
|
-
const { openCrop } = useInspector();
|
|
737
|
+
const { openCrop, openReplace } = useInspector();
|
|
764
738
|
const isImage = anchor.tagName === 'IMG';
|
|
765
739
|
return (
|
|
766
740
|
<div className="space-y-2">
|
|
@@ -782,7 +756,7 @@ function ImageField({
|
|
|
782
756
|
variant="outline"
|
|
783
757
|
size="sm"
|
|
784
758
|
className="flex-1"
|
|
785
|
-
onClick={() =>
|
|
759
|
+
onClick={() => openReplace(anchor)}
|
|
786
760
|
>
|
|
787
761
|
<ImageIcon className="size-3.5" />
|
|
788
762
|
{t.inspector.replace}
|
|
@@ -801,36 +775,6 @@ function ImageField({
|
|
|
801
775
|
)}
|
|
802
776
|
</div>
|
|
803
777
|
</div>
|
|
804
|
-
{open && (
|
|
805
|
-
<AssetPickerDialog
|
|
806
|
-
slideId={slideId}
|
|
807
|
-
onClose={() => setOpen(false)}
|
|
808
|
-
onPick={(asset, scope) => {
|
|
809
|
-
setOpen(false);
|
|
810
|
-
const assetPath =
|
|
811
|
-
scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
812
|
-
const ops: EditOp[] = [
|
|
813
|
-
{
|
|
814
|
-
kind: 'set-attr-asset',
|
|
815
|
-
attr: 'src',
|
|
816
|
-
assetPath,
|
|
817
|
-
previewUrl: asset.url,
|
|
818
|
-
},
|
|
819
|
-
];
|
|
820
|
-
if (isImage) {
|
|
821
|
-
const cs = window.getComputedStyle(anchor);
|
|
822
|
-
if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
|
|
823
|
-
ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
|
|
824
|
-
}
|
|
825
|
-
const op = cs.objectPosition.trim();
|
|
826
|
-
if (!op || op === '0% 0%' || op === 'auto') {
|
|
827
|
-
ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
apply(ops);
|
|
831
|
-
}}
|
|
832
|
-
/>
|
|
833
|
-
)}
|
|
834
778
|
</div>
|
|
835
779
|
);
|
|
836
780
|
}
|
|
@@ -894,187 +838,6 @@ function PlaceholderField({
|
|
|
894
838
|
);
|
|
895
839
|
}
|
|
896
840
|
|
|
897
|
-
type PickerScope = 'slide' | 'global';
|
|
898
|
-
const GLOBAL_PICKER_SLIDE_ID = '@global';
|
|
899
|
-
|
|
900
|
-
function AssetPickerDialog({
|
|
901
|
-
slideId,
|
|
902
|
-
onClose,
|
|
903
|
-
onPick,
|
|
904
|
-
}: {
|
|
905
|
-
slideId: string;
|
|
906
|
-
onClose: () => void;
|
|
907
|
-
onPick: (asset: AssetEntry, scope: PickerScope) => void;
|
|
908
|
-
}) {
|
|
909
|
-
const [scope, setScope] = useState<PickerScope>('slide');
|
|
910
|
-
const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId;
|
|
911
|
-
const { assets, loading, refresh } = useAssets(effectiveSlideId);
|
|
912
|
-
const images = assets.filter((a) => a.mime.startsWith('image/'));
|
|
913
|
-
const t = useLocale();
|
|
914
|
-
const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
|
|
915
|
-
const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
|
|
916
|
-
const [uploading, setUploading] = useState(false);
|
|
917
|
-
const [dragActive, setDragActive] = useState(false);
|
|
918
|
-
const dragDepth = useRef(0);
|
|
919
|
-
const inputId = useId();
|
|
920
|
-
|
|
921
|
-
const handleFile = useCallback(
|
|
922
|
-
async (file: File) => {
|
|
923
|
-
if (!file.type.startsWith('image/')) return;
|
|
924
|
-
setUploading(true);
|
|
925
|
-
try {
|
|
926
|
-
const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
|
|
927
|
-
if (!ok || !entry) {
|
|
928
|
-
toast.error(format(t.asset.toastUploadFailed, { status }));
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
await refresh().catch(() => {});
|
|
932
|
-
onPick(entry, scope);
|
|
933
|
-
} finally {
|
|
934
|
-
setUploading(false);
|
|
935
|
-
}
|
|
936
|
-
},
|
|
937
|
-
[effectiveSlideId, scope, refresh, onPick, t],
|
|
938
|
-
);
|
|
939
|
-
|
|
940
|
-
return (
|
|
941
|
-
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
942
|
-
<DialogContent className="sm:max-w-xl">
|
|
943
|
-
<DialogHeader>
|
|
944
|
-
<DialogTitle>{t.inspector.replaceImageDialogTitle}</DialogTitle>
|
|
945
|
-
<DialogDescription>
|
|
946
|
-
{descPrefix}
|
|
947
|
-
<span className="font-mono">{path}</span>
|
|
948
|
-
{descSuffix}
|
|
949
|
-
</DialogDescription>
|
|
950
|
-
</DialogHeader>
|
|
951
|
-
<Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
|
|
952
|
-
<TabsList>
|
|
953
|
-
<TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
|
|
954
|
-
<TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
|
|
955
|
-
</TabsList>
|
|
956
|
-
</Tabs>
|
|
957
|
-
<label
|
|
958
|
-
htmlFor={inputId}
|
|
959
|
-
className={cn(
|
|
960
|
-
'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',
|
|
961
|
-
'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
|
|
962
|
-
uploading && 'pointer-events-none opacity-60',
|
|
963
|
-
)}
|
|
964
|
-
>
|
|
965
|
-
{uploading ? (
|
|
966
|
-
<Loader2 className="size-3.5 animate-spin" />
|
|
967
|
-
) : (
|
|
968
|
-
<Upload className="size-3.5" />
|
|
969
|
-
)}
|
|
970
|
-
<span>{t.asset.upload}</span>
|
|
971
|
-
</label>
|
|
972
|
-
<input
|
|
973
|
-
id={inputId}
|
|
974
|
-
type="file"
|
|
975
|
-
accept="image/*"
|
|
976
|
-
className="sr-only"
|
|
977
|
-
disabled={uploading}
|
|
978
|
-
onChange={(e) => {
|
|
979
|
-
const file = e.target.files?.[0];
|
|
980
|
-
e.target.value = '';
|
|
981
|
-
if (file) handleFile(file).catch(() => {});
|
|
982
|
-
}}
|
|
983
|
-
/>
|
|
984
|
-
<section
|
|
985
|
-
aria-label={t.inspector.replaceImageDialogTitle}
|
|
986
|
-
className="relative max-h-[60vh] overflow-y-auto"
|
|
987
|
-
onDragEnter={(e) => {
|
|
988
|
-
if (uploading || !hasFiles(e)) return;
|
|
989
|
-
e.preventDefault();
|
|
990
|
-
dragDepth.current += 1;
|
|
991
|
-
setDragActive(true);
|
|
992
|
-
}}
|
|
993
|
-
onDragOver={(e) => {
|
|
994
|
-
if (uploading || !hasFiles(e)) return;
|
|
995
|
-
e.preventDefault();
|
|
996
|
-
e.dataTransfer.dropEffect = 'copy';
|
|
997
|
-
}}
|
|
998
|
-
onDragLeave={() => {
|
|
999
|
-
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
1000
|
-
if (dragDepth.current === 0) setDragActive(false);
|
|
1001
|
-
}}
|
|
1002
|
-
onDrop={(e) => {
|
|
1003
|
-
if (uploading || !hasFiles(e)) return;
|
|
1004
|
-
e.preventDefault();
|
|
1005
|
-
dragDepth.current = 0;
|
|
1006
|
-
setDragActive(false);
|
|
1007
|
-
const file = e.dataTransfer.files?.[0];
|
|
1008
|
-
if (file) handleFile(file).catch(() => {});
|
|
1009
|
-
}}
|
|
1010
|
-
>
|
|
1011
|
-
{loading ? (
|
|
1012
|
-
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
|
1013
|
-
{t.inspector.pickerLoading}
|
|
1014
|
-
</p>
|
|
1015
|
-
) : images.length === 0 ? (
|
|
1016
|
-
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
|
1017
|
-
{t.inspector.pickerEmpty}
|
|
1018
|
-
</p>
|
|
1019
|
-
) : (
|
|
1020
|
-
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
|
|
1021
|
-
{images.map((asset) => (
|
|
1022
|
-
<button
|
|
1023
|
-
key={asset.name}
|
|
1024
|
-
type="button"
|
|
1025
|
-
onClick={() => onPick(asset, scope)}
|
|
1026
|
-
className={cn(
|
|
1027
|
-
'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
|
|
1028
|
-
'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
|
|
1029
|
-
)}
|
|
1030
|
-
>
|
|
1031
|
-
<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]">
|
|
1032
|
-
<img
|
|
1033
|
-
src={asset.url}
|
|
1034
|
-
alt=""
|
|
1035
|
-
className="size-full object-contain"
|
|
1036
|
-
draggable={false}
|
|
1037
|
-
/>
|
|
1038
|
-
</div>
|
|
1039
|
-
<div className="border-t px-2 py-1.5">
|
|
1040
|
-
<div className="truncate text-[11px] font-medium" title={asset.name}>
|
|
1041
|
-
{asset.name}
|
|
1042
|
-
</div>
|
|
1043
|
-
</div>
|
|
1044
|
-
</button>
|
|
1045
|
-
))}
|
|
1046
|
-
</div>
|
|
1047
|
-
)}
|
|
1048
|
-
{dragActive && (
|
|
1049
|
-
<div
|
|
1050
|
-
className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
|
|
1051
|
-
aria-hidden
|
|
1052
|
-
>
|
|
1053
|
-
<div className="absolute inset-0 bg-brand/5" />
|
|
1054
|
-
<div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
|
|
1055
|
-
<div className="absolute inset-x-0 bottom-4 flex justify-center">
|
|
1056
|
-
<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">
|
|
1057
|
-
<ArrowDownToLine className="size-3.5 text-brand" />
|
|
1058
|
-
<span>{t.asset.dropToUpload}</span>
|
|
1059
|
-
</div>
|
|
1060
|
-
</div>
|
|
1061
|
-
</div>
|
|
1062
|
-
)}
|
|
1063
|
-
</section>
|
|
1064
|
-
</DialogContent>
|
|
1065
|
-
</Dialog>
|
|
1066
|
-
);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
function hasFiles(e: React.DragEvent): boolean {
|
|
1070
|
-
const types = e.dataTransfer?.types;
|
|
1071
|
-
if (!types) return false;
|
|
1072
|
-
for (let i = 0; i < types.length; i++) {
|
|
1073
|
-
if (types[i] === 'Files') return true;
|
|
1074
|
-
}
|
|
1075
|
-
return false;
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
841
|
function AgentWatchingBadge() {
|
|
1079
842
|
const t = useLocale();
|
|
1080
843
|
const connected = useAgentSocketConnected();
|