@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.
- package/dist/{build-4wOJF1l4.js → build-6BeQ3cxb.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-evLWCV1-.js → config-AxZ5OE1u.js} +772 -201
- package/dist/{config-D2y1AXaN.d.ts → config-CtT8K4VF.d.ts} +1 -1
- package/dist/{dev-BUr0S-Ij.js → dev-C9eLmUEq.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +136 -24
- package/dist/{preview-DP_gIphz.js → preview-Cunm-f4i.js} +1 -1
- package/dist/{types-BVvl_xup.d.ts → types-CRHIeoNq.d.ts} +37 -4
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +5 -1
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +48 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +17 -2
- package/src/app/components/inspector/inspector-panel.tsx +90 -26
- package/src/app/components/inspector/inspector-provider.tsx +136 -1
- package/src/app/components/notes-drawer.tsx +117 -0
- package/src/app/components/player.tsx +26 -8
- package/src/app/components/present/overview-grid.tsx +2 -2
- package/src/app/components/present/use-idle.ts +6 -4
- package/src/app/components/style-panel/design-provider.tsx +13 -0
- package/src/app/components/style-panel/style-panel.tsx +23 -11
- package/src/app/components/thumbnail-rail.tsx +317 -55
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/routes/home.tsx +34 -12
- package/src/app/routes/presenter.tsx +27 -24
- package/src/app/routes/slide.tsx +238 -51
- package/src/locale/en.ts +35 -4
- package/src/locale/ja.ts +35 -4
- package/src/locale/types.ts +38 -4
- package/src/locale/zh-cn.ts +35 -4
- 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
|
<{pinSelected.anchor.tagName.toLowerCase()}>
|
|
150
152
|
</span>
|
|
151
153
|
</div>
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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
|
-
<
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
876
|
+
{t.inspector.commentShortcutHint}
|
|
813
877
|
</span>
|
|
814
878
|
<Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
|
|
815
|
-
{t.inspector.
|
|
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
|
|
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() {
|