@open-slide/core 1.0.6 → 1.2.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 (37) hide show
  1. package/dist/{build-4wOJF1l4.js → build-6BeQ3cxb.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-evLWCV1-.js → config-AxZ5OE1u.js} +772 -201
  4. package/dist/{config-D2y1AXaN.d.ts → config-CtT8K4VF.d.ts} +1 -1
  5. package/dist/{dev-BUr0S-Ij.js → dev-C9eLmUEq.js} +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/locale/index.d.ts +1 -1
  8. package/dist/locale/index.js +136 -24
  9. package/dist/{preview-DP_gIphz.js → preview-Cunm-f4i.js} +1 -1
  10. package/dist/{types-BVvl_xup.d.ts → types-CRHIeoNq.d.ts} +37 -4
  11. package/dist/vite/index.d.ts +2 -2
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +5 -1
  14. package/skills/current-slide/SKILL.md +110 -0
  15. package/skills/slide-authoring/SKILL.md +48 -1
  16. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  17. package/src/app/components/inspector/inspect-overlay.tsx +17 -2
  18. package/src/app/components/inspector/inspector-panel.tsx +90 -26
  19. package/src/app/components/inspector/inspector-provider.tsx +136 -1
  20. package/src/app/components/notes-drawer.tsx +117 -0
  21. package/src/app/components/player.tsx +26 -8
  22. package/src/app/components/present/overview-grid.tsx +2 -2
  23. package/src/app/components/present/use-idle.ts +6 -4
  24. package/src/app/components/style-panel/design-provider.tsx +13 -0
  25. package/src/app/components/style-panel/style-panel.tsx +23 -11
  26. package/src/app/components/thumbnail-rail.tsx +317 -55
  27. package/src/app/components/ui/context-menu.tsx +237 -0
  28. package/src/app/lib/design-presets.ts +94 -0
  29. package/src/app/lib/inspector/use-notes.ts +134 -0
  30. package/src/app/routes/home.tsx +34 -12
  31. package/src/app/routes/presenter.tsx +27 -24
  32. package/src/app/routes/slide.tsx +238 -51
  33. package/src/locale/en.ts +35 -4
  34. package/src/locale/ja.ts +35 -4
  35. package/src/locale/types.ts +38 -4
  36. package/src/locale/zh-cn.ts +35 -4
  37. package/src/locale/zh-tw.ts +35 -4
@@ -0,0 +1,212 @@
1
+ import { type SyntheticEvent, useEffect, useRef, useState } from 'react';
2
+ import ReactCrop, { type Crop, type PercentCrop } from 'react-image-crop';
3
+ import 'react-image-crop/dist/ReactCrop.css';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@/components/ui/dialog';
13
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
14
+ import { useLocale } from '@/lib/use-locale';
15
+
16
+ export type ImageCropRect = { x: number; y: number; width: number; height: number };
17
+
18
+ export type ImageCropResult = { fit: 'contain' } | { fit: 'cover'; rect: ImageCropRect };
19
+
20
+ export function ImageCropDialog({
21
+ src,
22
+ targetWidth,
23
+ targetHeight,
24
+ initialFit,
25
+ initialPosition,
26
+ initialRect,
27
+ onClose,
28
+ onApply,
29
+ }: {
30
+ src: string;
31
+ targetWidth: number;
32
+ targetHeight: number;
33
+ initialFit: 'cover' | 'contain';
34
+ initialPosition: { x: number; y: number };
35
+ initialRect: ImageCropRect | null;
36
+ onClose: () => void;
37
+ onApply: (result: ImageCropResult) => void;
38
+ }) {
39
+ const t = useLocale();
40
+ const [fit, setFit] = useState<'cover' | 'contain'>(initialFit);
41
+ const aspect = targetWidth > 0 && targetHeight > 0 ? targetWidth / targetHeight : 1;
42
+ const [crop, setCrop] = useState<Crop | undefined>(undefined);
43
+ const imgRef = useRef<HTMLImageElement>(null);
44
+
45
+ const onImageLoad = (e: SyntheticEvent<HTMLImageElement>) => {
46
+ const im = e.currentTarget;
47
+ setCrop(initialCrop(im.naturalWidth, im.naturalHeight, aspect, initialRect, initialPosition));
48
+ };
49
+
50
+ useEffect(() => {
51
+ const im = imgRef.current;
52
+ if (!im?.complete || !im.naturalWidth || !im.naturalHeight) return;
53
+ setCrop((prev) => {
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);
58
+ });
59
+ }, [aspect, initialPosition, initialRect]);
60
+
61
+ const onApplyClick = () => {
62
+ if (fit === 'contain') {
63
+ onApply({ fit });
64
+ return;
65
+ }
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 });
71
+ };
72
+
73
+ return (
74
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
75
+ <DialogContent className="sm:max-w-2xl">
76
+ <DialogHeader>
77
+ <DialogTitle>{t.inspector.cropDialogTitle}</DialogTitle>
78
+ <DialogDescription>{t.inspector.cropDialogDescription}</DialogDescription>
79
+ </DialogHeader>
80
+ <div className="flex justify-center">
81
+ <ToggleGroup
82
+ type="single"
83
+ value={fit}
84
+ onValueChange={(v) => {
85
+ if (v === 'cover' || v === 'contain') setFit(v);
86
+ }}
87
+ variant="outline"
88
+ size="sm"
89
+ >
90
+ <ToggleGroupItem value="cover" className="text-xs">
91
+ {t.inspector.cropFitCover}
92
+ </ToggleGroupItem>
93
+ <ToggleGroupItem value="contain" className="text-xs">
94
+ {t.inspector.cropFitContain}
95
+ </ToggleGroupItem>
96
+ </ToggleGroup>
97
+ </div>
98
+ <div className="flex h-[420px] w-full items-center justify-center overflow-hidden rounded-md border bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:12px_12px]">
99
+ {fit === 'cover' ? (
100
+ <ReactCrop
101
+ crop={crop}
102
+ onChange={(_, percentCrop) => setCrop(percentCrop)}
103
+ aspect={aspect}
104
+ keepSelection
105
+ className="max-h-full"
106
+ >
107
+ <img
108
+ ref={imgRef}
109
+ src={src}
110
+ alt=""
111
+ style={{ maxHeight: 420, maxWidth: '100%' }}
112
+ onLoad={onImageLoad}
113
+ />
114
+ </ReactCrop>
115
+ ) : (
116
+ <img src={src} alt="" className="max-h-full max-w-full object-contain" />
117
+ )}
118
+ </div>
119
+ <DialogFooter>
120
+ <Button variant="outline" onClick={onClose}>
121
+ {t.common.cancel}
122
+ </Button>
123
+ <Button onClick={onApplyClick}>{t.inspector.cropApply}</Button>
124
+ </DialogFooter>
125
+ </DialogContent>
126
+ </Dialog>
127
+ );
128
+ }
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
+
143
+ function makeMaxSizeCrop(
144
+ naturalW: number,
145
+ naturalH: number,
146
+ aspect: number,
147
+ position: { x: number; y: number },
148
+ ): PercentCrop {
149
+ if (naturalW <= 0 || naturalH <= 0) {
150
+ return { unit: '%', x: 0, y: 0, width: 100, height: 100 };
151
+ }
152
+ const sourceAspect = naturalW / naturalH;
153
+ let width = 100;
154
+ let height = 100;
155
+ if (aspect >= sourceAspect) {
156
+ width = 100;
157
+ height = (sourceAspect / aspect) * 100;
158
+ } else {
159
+ height = 100;
160
+ width = (aspect / sourceAspect) * 100;
161
+ }
162
+ const slackX = 100 - width;
163
+ const slackY = 100 - height;
164
+ const x = clamp((position.x / 100) * slackX, 0, slackX);
165
+ const y = clamp((position.y / 100) * slackY, 0, slackY);
166
+ return { unit: '%', x, y, width, height };
167
+ }
168
+
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 {
198
+ return {
199
+ x: round2(crop.x),
200
+ y: round2(crop.y),
201
+ width: round2(crop.width),
202
+ height: round2(crop.height),
203
+ };
204
+ }
205
+
206
+ function clamp(v: number, lo: number, hi: number) {
207
+ return v < lo ? lo : v > hi ? hi : v;
208
+ }
209
+
210
+ function round2(n: number): number {
211
+ return Math.round(n * 100) / 100;
212
+ }
@@ -12,7 +12,7 @@ const FRAME_MORPH_MS = 180;
12
12
  const LAYOUT_TRACK_MS = PANEL_TRANSITION_MS + FRAME_MORPH_MS;
13
13
 
14
14
  export function InspectOverlay() {
15
- const { active, slideId, selected, setSelected, cancel } = useInspector();
15
+ const { active, slideId, selected, setSelected, cancel, openCrop } = useInspector();
16
16
  const overlayRef = useRef<HTMLDivElement>(null);
17
17
  const [hover, setHover] = useState<Highlight | null>(null);
18
18
 
@@ -50,15 +50,30 @@ export function InspectOverlay() {
50
50
  setHover({ hit });
51
51
  };
52
52
 
53
+ const onDblClick = (e: MouseEvent) => {
54
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
55
+ const el = pickElement(e.clientX, e.clientY);
56
+ if (!el) return;
57
+ const hit = findSlideSource(el, slideId, { hostOnly: true });
58
+ if (!hit) return;
59
+ if (!(hit.anchor instanceof HTMLImageElement)) return;
60
+ e.preventDefault();
61
+ e.stopPropagation();
62
+ setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
63
+ openCrop(hit.anchor);
64
+ };
65
+
53
66
  window.addEventListener('pointermove', onMove, true);
54
67
  window.addEventListener('click', onClick, true);
68
+ window.addEventListener('dblclick', onDblClick, true);
55
69
  window.addEventListener('keydown', onKey, true);
56
70
  return () => {
57
71
  window.removeEventListener('pointermove', onMove, true);
58
72
  window.removeEventListener('click', onClick, true);
73
+ window.removeEventListener('dblclick', onDblClick, true);
59
74
  window.removeEventListener('keydown', onKey, true);
60
75
  };
61
- }, [active, slideId, setSelected, cancel]);
76
+ }, [active, slideId, setSelected, cancel, openCrop]);
62
77
 
63
78
  return (
64
79
  <FrameOverlay
@@ -4,6 +4,7 @@ import {
4
4
  AlignLeft,
5
5
  AlignRight,
6
6
  Bold,
7
+ Crop,
7
8
  ImageIcon,
8
9
  Italic,
9
10
  X,
@@ -32,6 +33,7 @@ import { Slider } from '@/components/ui/slider';
32
33
  import { Textarea } from '@/components/ui/textarea';
33
34
  import { Toggle } from '@/components/ui/toggle';
34
35
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
36
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
35
37
  import { type AssetEntry, useAssets } from '@/lib/assets';
36
38
  import { findSlideSource } from '@/lib/inspector/fiber';
37
39
  import type { EditOp } from '@/lib/inspector/use-editor';
@@ -149,15 +151,18 @@ export function InspectorPanel() {
149
151
  &lt;{pinSelected.anchor.tagName.toLowerCase()}&gt;
150
152
  </span>
151
153
  </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>
154
+ <div className="flex items-center gap-1.5">
155
+ <AgentWatchingBadge />
156
+ <Button
157
+ variant="ghost"
158
+ size="icon-sm"
159
+ className="text-muted-foreground hover:text-foreground"
160
+ onClick={() => setSelected(null)}
161
+ aria-label={t.inspector.deselect}
162
+ >
163
+ <X className="size-3.5" />
164
+ </Button>
165
+ </div>
161
166
  </>
162
167
  }
163
168
  footer={<CommentsSection selected={pinSelected} onAdd={add} />}
@@ -202,7 +207,12 @@ export function InspectorPanel() {
202
207
  <>
203
208
  <Separator />
204
209
  <Section title={t.inspector.imageSection}>
205
- <ImageField slideId={slideId} src={pinSnapshot.imageSrc} apply={apply} />
210
+ <ImageField
211
+ slideId={slideId}
212
+ src={pinSnapshot.imageSrc}
213
+ anchor={pinSelected.anchor}
214
+ apply={apply}
215
+ />
206
216
  </Section>
207
217
  </>
208
218
  )}
@@ -587,14 +597,18 @@ function ColorField({
587
597
  function ImageField({
588
598
  slideId,
589
599
  src,
600
+ anchor,
590
601
  apply,
591
602
  }: {
592
603
  slideId: string;
593
604
  src: string;
605
+ anchor: HTMLElement;
594
606
  apply: (ops: EditOp[]) => void;
595
607
  }) {
596
608
  const [open, setOpen] = useState(false);
597
609
  const t = useLocale();
610
+ const { openCrop } = useInspector();
611
+ const isImage = anchor.tagName === 'IMG';
598
612
  return (
599
613
  <div className="space-y-2">
600
614
  <div className="flex items-center gap-3">
@@ -609,16 +623,30 @@ function ImageField({
609
623
  }}
610
624
  />
611
625
  </div>
612
- <Button
613
- type="button"
614
- variant="outline"
615
- size="sm"
616
- className="flex-1"
617
- onClick={() => setOpen(true)}
618
- >
619
- <ImageIcon className="size-3.5" />
620
- {t.inspector.replace}
621
- </Button>
626
+ <div className="flex flex-1 gap-2">
627
+ <Button
628
+ type="button"
629
+ variant="outline"
630
+ size="sm"
631
+ className="flex-1"
632
+ onClick={() => setOpen(true)}
633
+ >
634
+ <ImageIcon className="size-3.5" />
635
+ {t.inspector.replace}
636
+ </Button>
637
+ {isImage && (
638
+ <Button
639
+ type="button"
640
+ variant="outline"
641
+ size="sm"
642
+ className="flex-1"
643
+ onClick={() => openCrop(anchor as HTMLImageElement)}
644
+ >
645
+ <Crop className="size-3.5" />
646
+ {t.inspector.crop}
647
+ </Button>
648
+ )}
649
+ </div>
622
650
  </div>
623
651
  {open && (
624
652
  <AssetPickerDialog
@@ -626,14 +654,25 @@ function ImageField({
626
654
  onClose={() => setOpen(false)}
627
655
  onPick={(asset) => {
628
656
  setOpen(false);
629
- apply([
657
+ const ops: EditOp[] = [
630
658
  {
631
659
  kind: 'set-attr-asset',
632
660
  attr: 'src',
633
661
  assetPath: `./assets/${asset.name}`,
634
662
  previewUrl: asset.url,
635
663
  },
636
- ]);
664
+ ];
665
+ if (isImage) {
666
+ const cs = window.getComputedStyle(anchor);
667
+ if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
668
+ ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
669
+ }
670
+ const op = cs.objectPosition.trim();
671
+ if (!op || op === '0% 0%' || op === 'auto') {
672
+ ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
673
+ }
674
+ }
675
+ apply(ops);
637
676
  }}
638
677
  />
639
678
  )}
@@ -767,6 +806,31 @@ function AssetPickerDialog({
767
806
  );
768
807
  }
769
808
 
809
+ function AgentWatchingBadge() {
810
+ const t = useLocale();
811
+ return (
812
+ <TooltipProvider delayDuration={200}>
813
+ <Tooltip>
814
+ <TooltipTrigger asChild>
815
+ <button
816
+ type="button"
817
+ 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"
818
+ >
819
+ <span aria-hidden className="relative flex size-1.5 items-center justify-center">
820
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
821
+ <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
822
+ </span>
823
+ {t.inspector.agentWatching}
824
+ </button>
825
+ </TooltipTrigger>
826
+ <TooltipContent side="bottom" align="end" className="max-w-[260px] leading-relaxed">
827
+ {t.inspector.agentWatchingTooltip}
828
+ </TooltipContent>
829
+ </Tooltip>
830
+ </TooltipProvider>
831
+ );
832
+ }
833
+
770
834
  function CommentsSection({
771
835
  selected,
772
836
  onAdd,
@@ -791,7 +855,7 @@ function CommentsSection({
791
855
  };
792
856
 
793
857
  return (
794
- <Section title={t.inspector.noteForAgent}>
858
+ <Section title={t.inspector.leaveComment}>
795
859
  <div className="flex flex-col gap-2">
796
860
  <div className="comment-cue rounded-[6px]">
797
861
  <Textarea
@@ -803,16 +867,16 @@ function CommentsSection({
803
867
  submit();
804
868
  }
805
869
  }}
806
- placeholder={t.inspector.noteAgentPlaceholder}
870
+ placeholder={t.inspector.commentPlaceholder}
807
871
  className="min-h-16 resize-none text-[12px]"
808
872
  />
809
873
  </div>
810
874
  <div className="flex items-center justify-between gap-2">
811
875
  <span className="font-mono text-[10.5px] text-muted-foreground/70">
812
- {t.inspector.noteShortcutHint}
876
+ {t.inspector.commentShortcutHint}
813
877
  </span>
814
878
  <Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
815
- {t.inspector.addNote}
879
+ {t.inspector.addComment}
816
880
  </Button>
817
881
  </div>
818
882
  </div>
@@ -15,6 +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, type ImageCropRect } from './image-crop-dialog';
18
19
 
19
20
  export type SelectedTarget = {
20
21
  line: number;
@@ -69,6 +70,7 @@ type InspectorCtx = {
69
70
  commitEdits: () => Promise<void>;
70
71
  cancelEdits: () => void;
71
72
  committing: boolean;
73
+ openCrop: (anchor: HTMLImageElement) => void;
72
74
  };
73
75
 
74
76
  const Ctx = createContext<InspectorCtx | null>(null);
@@ -90,6 +92,17 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
90
92
  const instanceCounterRef = useRef(0);
91
93
  const [pendingCount, setPendingCount] = useState(0);
92
94
  const [committing, setCommitting] = useState(false);
95
+ const [cropTarget, setCropTarget] = useState<{
96
+ line: number;
97
+ column: number;
98
+ anchor: HTMLImageElement;
99
+ src: string;
100
+ targetWidth: number;
101
+ targetHeight: number;
102
+ initialFit: 'cover' | 'contain';
103
+ initialPosition: { x: number; y: number };
104
+ initialRect: ImageCropRect | null;
105
+ } | null>(null);
93
106
  const t = useLocale();
94
107
 
95
108
  const ensureInstanceId = useCallback((el: HTMLElement): string => {
@@ -529,6 +542,27 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
529
542
  setSelected(null);
530
543
  }, []);
531
544
 
545
+ const openCrop = useCallback((anchor: HTMLImageElement) => {
546
+ const loc = anchor.dataset.slideLoc;
547
+ if (!loc) return;
548
+ const [lineStr, columnStr] = loc.split(':');
549
+ const line = Number(lineStr);
550
+ const column = Number(columnStr);
551
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
552
+ const cs = window.getComputedStyle(anchor);
553
+ setCropTarget({
554
+ line,
555
+ column,
556
+ anchor,
557
+ src: anchor.currentSrc || anchor.src,
558
+ targetWidth: anchor.offsetWidth || anchor.getBoundingClientRect().width,
559
+ targetHeight: anchor.offsetHeight || anchor.getBoundingClientRect().height,
560
+ initialFit: cs.objectFit === 'contain' ? 'contain' : 'cover',
561
+ initialPosition: parseObjectPosition(cs.objectPosition),
562
+ initialRect: parseObjectViewBox(cs.getPropertyValue('object-view-box')),
563
+ });
564
+ }, []);
565
+
532
566
  const value = useMemo<InspectorCtx>(
533
567
  () => ({
534
568
  slideId,
@@ -549,6 +583,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
549
583
  commitEdits,
550
584
  cancelEdits,
551
585
  committing,
586
+ openCrop,
552
587
  }),
553
588
  [
554
589
  slideId,
@@ -568,10 +603,110 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
568
603
  commitEdits,
569
604
  cancelEdits,
570
605
  committing,
606
+ openCrop,
571
607
  ],
572
608
  );
573
609
 
574
- return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
610
+ return (
611
+ <Ctx.Provider value={value}>
612
+ {children}
613
+ {cropTarget && (
614
+ <ImageCropDialog
615
+ src={cropTarget.src}
616
+ targetWidth={cropTarget.targetWidth}
617
+ targetHeight={cropTarget.targetHeight}
618
+ initialFit={cropTarget.initialFit}
619
+ initialPosition={cropTarget.initialPosition}
620
+ initialRect={cropTarget.initialRect}
621
+ onClose={() => setCropTarget(null)}
622
+ onApply={(result) => {
623
+ const { line, column, anchor } = cropTarget;
624
+ if (anchor.isConnected) {
625
+ const ops: EditOp[] = [
626
+ { kind: 'set-style', key: 'objectFit', value: result.fit },
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({
636
+ kind: 'set-style',
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);
644
+ }
645
+ setCropTarget(null);
646
+ }}
647
+ />
648
+ )}
649
+ </Ctx.Provider>
650
+ );
651
+ }
652
+
653
+ function round2(n: number): number {
654
+ return Math.round(n * 100) / 100;
655
+ }
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
+
696
+ function parseObjectPosition(value: string): { x: number; y: number } {
697
+ const parts = value.trim().split(/\s+/);
698
+ const xRaw = parts[0] ?? '50%';
699
+ const yRaw = parts[1] ?? xRaw;
700
+ return { x: parsePercent(xRaw, 50), y: parsePercent(yRaw, 50) };
701
+ }
702
+
703
+ function parsePercent(s: string, fallback: number): number {
704
+ if (s === 'center') return 50;
705
+ if (s === 'left' || s === 'top') return 0;
706
+ if (s === 'right' || s === 'bottom') return 100;
707
+ const m = s.match(/(-?\d+(?:\.\d+)?)%/);
708
+ if (m?.[1]) return Number(m[1]);
709
+ return fallback;
575
710
  }
576
711
 
577
712
  export function InspectToggleButton() {