@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.
Files changed (55) hide show
  1. package/dist/{build-DSqSio-T.js → build-_276DMmJ.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-KdiYeWtK.js → config-BAwKWNtW.js} +888 -229
  4. package/dist/{config-C7vMYzFD.d.ts → config-D9cZ1A0X.d.ts} +2 -1
  5. package/dist/{dev-B_GVbr11.js → dev-BoqeVXVq.js} +2 -2
  6. package/dist/en-CDKzoZvf.js +351 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +229 -39
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +166 -326
  11. package/dist/{preview-D_mxhj7w.js → preview-BLPxspc9.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-DYgVpIGo.d.ts → types-JYG1cmwC.d.ts} +59 -5
  14. package/dist/vite/index.d.ts +2 -2
  15. package/dist/vite/index.js +2 -2
  16. package/package.json +9 -1
  17. package/skills/create-slide/SKILL.md +1 -1
  18. package/skills/create-theme/SKILL.md +60 -12
  19. package/skills/current-slide/SKILL.md +110 -0
  20. package/skills/slide-authoring/SKILL.md +59 -1
  21. package/src/app/app.tsx +11 -1
  22. package/src/app/components/asset-view.tsx +1 -13
  23. package/src/app/components/image-placeholder.tsx +123 -1
  24. package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
  25. package/src/app/components/inspector/inspector-panel.tsx +163 -19
  26. package/src/app/components/inspector/inspector-provider.tsx +60 -7
  27. package/src/app/components/notes-drawer.tsx +117 -0
  28. package/src/app/components/player.tsx +11 -7
  29. package/src/app/components/present/overview-grid.tsx +2 -2
  30. package/src/app/components/sidebar/folder-item.tsx +16 -5
  31. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  32. package/src/app/components/sidebar/sidebar.tsx +10 -0
  33. package/src/app/components/themes/theme-detail.tsx +300 -0
  34. package/src/app/components/themes/themes-gallery.tsx +146 -0
  35. package/src/app/components/thumbnail-rail.tsx +136 -29
  36. package/src/app/components/ui/context-menu.tsx +237 -0
  37. package/src/app/lib/assets.ts +55 -2
  38. package/src/app/lib/inspector/use-notes.ts +134 -0
  39. package/src/app/lib/sdk.ts +1 -0
  40. package/src/app/lib/slides.ts +10 -1
  41. package/src/app/lib/themes.ts +22 -0
  42. package/src/app/lib/use-agent-socket.ts +18 -0
  43. package/src/app/routes/home-shell.tsx +173 -0
  44. package/src/app/routes/home.tsx +108 -204
  45. package/src/app/routes/slide.tsx +333 -68
  46. package/src/app/routes/themes.tsx +34 -0
  47. package/src/app/virtual.d.ts +20 -0
  48. package/src/locale/en.ts +61 -7
  49. package/src/locale/ja.ts +62 -7
  50. package/src/locale/types.ts +62 -5
  51. package/src/locale/zh-cn.ts +61 -7
  52. package/src/locale/zh-tw.ts +61 -7
  53. package/dist/sync-B4eLo2H6.js +0 -3
  54. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  55. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
@@ -1,4 +1,7 @@
1
- import type { CSSProperties, HTMLAttributes } from 'react';
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 ImageCropResult = {
17
- fit: 'cover' | 'contain';
18
- x: number;
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(makeMaxSizeCrop(im.naturalWidth, im.naturalHeight, aspect, initialPosition));
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 || !im.complete || !im.naturalWidth || !im.naturalHeight) return;
52
+ if (!im?.complete || !im.naturalWidth || !im.naturalHeight) return;
53
53
  setCrop((prev) => {
54
- const pos = prev ? deriveObjectPosition(prev as PercentCrop) : initialPosition;
55
- return makeMaxSizeCrop(im.naturalWidth, im.naturalHeight, aspect, pos);
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, x: 50, y: 50 });
63
+ onApply({ fit });
62
64
  return;
63
65
  }
64
- const pos =
65
- crop && crop.unit === '%' ? deriveObjectPosition(crop as PercentCrop) : { x: 50, y: 50 };
66
- onApply({ fit, x: round2(pos.x), y: round2(pos.y) });
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 deriveObjectPosition(crop: PercentCrop): { x: number; y: number } {
154
- const slackX = 100 - crop.width;
155
- const slackY = 100 - crop.height;
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: slackX > 0 ? clamp((crop.x / slackX) * 100, 0, 100) : 50,
158
- y: slackY > 0 ? clamp((crop.y / slackY) * 100, 0, 100) : 50,
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 { type AssetEntry, useAssets } from '@/lib/assets';
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 { useLocale } from '@/lib/use-locale';
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
  &lt;{pinSelected.anchor.tagName.toLowerCase()}&gt;
150
157
  </span>
151
158
  </div>
152
- <Button
153
- variant="ghost"
154
- size="icon-sm"
155
- className="text-muted-foreground hover:text-foreground"
156
- onClick={() => setSelected(null)}
157
- aria-label={t.inspector.deselect}
158
- >
159
- <X className="size-3.5" />
160
- </Button>
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
- <div className="max-h-[60vh] overflow-y-auto">
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
- </div>
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.noteForAgent}>
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.noteAgentPlaceholder}
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.noteShortcutHint}
989
+ {t.inspector.commentShortcutHint}
846
990
  </span>
847
991
  <Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
848
- {t.inspector.addNote}
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
- bufferOps(line, column, anchor, [
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: 'objectPosition',
627
- value: `${round2(result.x)}% ${round2(result.y)}%`,
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%';