@sentroy-co/client-sdk 2.13.3 → 2.13.6
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/README.md +52 -0
- package/dist/react/crop/CropDialog.d.ts +1 -2
- package/dist/react/crop/CropDialog.d.ts.map +1 -1
- package/dist/react/crop/CropDialog.js +51 -164
- package/dist/react/crop/CropDialog.js.map +1 -1
- package/dist/react/crop/styles.css +821 -0
- package/package.json +4 -3
- package/src/react/crop/CropDialog.tsx +141 -375
|
@@ -1,30 +1,21 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
2
|
-
import { Cropper, type CropperRef } from "react-
|
|
2
|
+
import { Cropper, type CropperRef } from "react-mobile-cropper"
|
|
3
3
|
import { motion, AnimatePresence } from "motion/react"
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Image crop dialog — iOS Photos benzeri full-screen crop UI.
|
|
7
|
-
* `react-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Image crop dialog — iOS Photos benzeri full-screen crop UI.
|
|
7
|
+
* `react-mobile-cropper` üzerine kurulu; paket native olarak iOS-vari
|
|
8
|
+
* stencil + handler + transition davranışı veriyor (manual layout/icon
|
|
9
|
+
* gerek yok).
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* iOS Photos pattern:
|
|
12
|
+
* - Header: Cancel (sol) — "Crop" başlığı (orta) — Done (sağ)
|
|
13
|
+
* - Main: Cropper full width, stencil ile karartılmış kenar
|
|
14
|
+
* - Bottom toolbar: aspect chip'leri (Free / 1:1 / 4:3 / 16:9 / 3:2 / 9:16)
|
|
15
|
+
* + sağ tarafta tek rotate ikonu
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* - Rotate 90° CW/CCW butonları (R / Shift+R)
|
|
20
|
-
* - Cropper'ın `getCanvas()` çıktısından **live preview**
|
|
21
|
-
* thumbnail — kullanıcı Apply'a basmadan sonucu görür.
|
|
22
|
-
* - Output pixel boyutu (örn. 1200×800) read-out.
|
|
23
|
-
* - Apply: ref üzerinden `getCanvas().toBlob` → File.
|
|
24
|
-
* - "Use original": orijinal File döner; Cancel: null.
|
|
25
|
-
*
|
|
26
|
-
* Tam ekran: `inset-0`, scrim'siz; consent flow gibi destination-only UX —
|
|
27
|
-
* dialog her şeyi kaplar, dikkat dağıtmaz.
|
|
17
|
+
* Preview thumbnail kasıtlı olarak yok — iOS Photos'ta da yok; kullanıcı
|
|
18
|
+
* stencil'in içini direkt görüyor.
|
|
28
19
|
*
|
|
29
20
|
* Lazy subpath (`@sentroy-co/client-sdk/react/crop`) — ana SDK import'u
|
|
30
21
|
* cropper bundle'ı yutmasın.
|
|
@@ -43,8 +34,7 @@ const ASPECT_PRESETS: Array<{
|
|
|
43
34
|
{ id: "9:16", label: "9:16", aspect: 9 / 16 },
|
|
44
35
|
]
|
|
45
36
|
|
|
46
|
-
const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP
|
|
47
|
-
const PREVIEW_MAX_DIM = 240
|
|
37
|
+
const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP
|
|
48
38
|
|
|
49
39
|
export interface CropDialogProps {
|
|
50
40
|
open: boolean
|
|
@@ -55,8 +45,7 @@ export interface CropDialogProps {
|
|
|
55
45
|
onClose: (result: File | null) => void
|
|
56
46
|
/** Default aspect preset id'si — 'free' (default) veya '1:1', '16:9', vb. */
|
|
57
47
|
defaultAspect?: string
|
|
58
|
-
/** Output JPEG quality 0-1 (default 0.92).
|
|
59
|
-
* veya orijinal MIME (PNG'ler için PNG korunur). */
|
|
48
|
+
/** Output JPEG quality 0-1 (default 0.92). PNG'ler için PNG korunur. */
|
|
60
49
|
outputQuality?: number
|
|
61
50
|
}
|
|
62
51
|
|
|
@@ -71,13 +60,7 @@ export function CropDialog({
|
|
|
71
60
|
const [aspectId, setAspectId] = useState(defaultAspect)
|
|
72
61
|
const [busy, setBusy] = useState(false)
|
|
73
62
|
const [tooLarge, setTooLarge] = useState(false)
|
|
74
|
-
const [outputSize, setOutputSize] = useState<{ w: number; h: number } | null>(
|
|
75
|
-
null,
|
|
76
|
-
)
|
|
77
|
-
const [previewDataUrl, setPreviewDataUrl] = useState<string | null>(null)
|
|
78
|
-
|
|
79
63
|
const cropperRef = useRef<CropperRef | null>(null)
|
|
80
|
-
const previewRafRef = useRef<number | null>(null)
|
|
81
64
|
|
|
82
65
|
// Object URL lifecycle
|
|
83
66
|
useEffect(() => {
|
|
@@ -86,9 +69,6 @@ export function CropDialog({
|
|
|
86
69
|
setImageUrl(url)
|
|
87
70
|
setAspectId(defaultAspect)
|
|
88
71
|
setTooLarge(false)
|
|
89
|
-
setOutputSize(null)
|
|
90
|
-
setPreviewDataUrl(null)
|
|
91
|
-
// Pixel guard — large image decode tarayıcıyı çökertir
|
|
92
72
|
const img = new Image()
|
|
93
73
|
img.onload = () => {
|
|
94
74
|
if (img.naturalWidth * img.naturalHeight > MAX_PIXEL_GUARD) {
|
|
@@ -96,86 +76,36 @@ export function CropDialog({
|
|
|
96
76
|
}
|
|
97
77
|
}
|
|
98
78
|
img.src = url
|
|
99
|
-
return () =>
|
|
100
|
-
URL.revokeObjectURL(url)
|
|
101
|
-
if (previewRafRef.current !== null) {
|
|
102
|
-
cancelAnimationFrame(previewRafRef.current)
|
|
103
|
-
previewRafRef.current = null
|
|
104
|
-
}
|
|
105
|
-
}
|
|
79
|
+
return () => URL.revokeObjectURL(url)
|
|
106
80
|
}, [open, file, defaultAspect])
|
|
107
81
|
|
|
108
82
|
const aspectRatio =
|
|
109
83
|
ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
|
|
110
84
|
|
|
111
|
-
// Aspect preset değişirse
|
|
112
|
-
// güncelle. `setCoordinates` ile aspect'i zorla.
|
|
85
|
+
// Aspect preset değişirse stencil koordinatlarını yeni aspect'e snap'le
|
|
113
86
|
useEffect(() => {
|
|
114
|
-
|
|
115
|
-
if (aspectRatio === undefined) return
|
|
116
|
-
const state =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
// Aspect ratio'ya snap — width'i koru, height'i hesapla
|
|
123
|
-
const newWidth = coordinates.width
|
|
124
|
-
const newHeight = coordinates.width / aspectRatio
|
|
125
|
-
cropperRef.current.setCoordinates({
|
|
126
|
-
width: newWidth,
|
|
127
|
-
height: newHeight,
|
|
128
|
-
})
|
|
87
|
+
const cropper = cropperRef.current
|
|
88
|
+
if (!cropper || aspectRatio === undefined) return
|
|
89
|
+
const state = cropper.getState()
|
|
90
|
+
const c = state?.coordinates
|
|
91
|
+
if (!c) return
|
|
92
|
+
const newWidth = c.width
|
|
93
|
+
const newHeight = c.width / aspectRatio
|
|
94
|
+
cropper.setCoordinates({ width: newWidth, height: newHeight })
|
|
129
95
|
}, [aspectRatio, aspectId])
|
|
130
96
|
|
|
131
|
-
// Live preview render — cropper change event'inde getCanvas() ile
|
|
132
|
-
// küçük thumbnail üret. RAF ile throttle.
|
|
133
|
-
const renderPreview = useCallback(() => {
|
|
134
|
-
if (previewRafRef.current !== null) {
|
|
135
|
-
cancelAnimationFrame(previewRafRef.current)
|
|
136
|
-
}
|
|
137
|
-
previewRafRef.current = requestAnimationFrame(() => {
|
|
138
|
-
previewRafRef.current = null
|
|
139
|
-
const cropper = cropperRef.current
|
|
140
|
-
if (!cropper) return
|
|
141
|
-
const canvas = cropper.getCanvas({
|
|
142
|
-
// Preview canvas — düşük boyut, smooth (output quality değil)
|
|
143
|
-
maxWidth: PREVIEW_MAX_DIM * 2,
|
|
144
|
-
maxHeight: PREVIEW_MAX_DIM * 2,
|
|
145
|
-
imageSmoothingQuality: "medium",
|
|
146
|
-
})
|
|
147
|
-
if (!canvas) return
|
|
148
|
-
setOutputSize({ w: canvas.width, h: canvas.height })
|
|
149
|
-
// Data URL — küçük canvas; performant
|
|
150
|
-
try {
|
|
151
|
-
setPreviewDataUrl(canvas.toDataURL("image/jpeg", 0.7))
|
|
152
|
-
} catch {
|
|
153
|
-
// toDataURL nadir tainted-canvas durumunda fail edebilir; sessiz geç
|
|
154
|
-
}
|
|
155
|
-
})
|
|
156
|
-
}, [])
|
|
157
|
-
|
|
158
|
-
const handleCropperChange = useCallback(() => {
|
|
159
|
-
renderPreview()
|
|
160
|
-
}, [renderPreview])
|
|
161
|
-
|
|
162
|
-
const handleCropperReady = useCallback(() => {
|
|
163
|
-
renderPreview()
|
|
164
|
-
}, [renderPreview])
|
|
165
|
-
|
|
166
97
|
const handleApply = useCallback(async () => {
|
|
167
98
|
const cropper = cropperRef.current
|
|
168
99
|
if (!cropper) return
|
|
169
100
|
setBusy(true)
|
|
170
101
|
try {
|
|
171
|
-
const canvas = cropper.getCanvas({
|
|
172
|
-
imageSmoothingQuality: "high",
|
|
173
|
-
})
|
|
102
|
+
const canvas = cropper.getCanvas({ imageSmoothingQuality: "high" })
|
|
174
103
|
if (!canvas) {
|
|
175
104
|
setBusy(false)
|
|
176
105
|
return
|
|
177
106
|
}
|
|
178
|
-
const outputMime =
|
|
107
|
+
const outputMime =
|
|
108
|
+
file.type === "image/png" ? "image/png" : "image/jpeg"
|
|
179
109
|
const blob = await new Promise<Blob | null>((resolve) => {
|
|
180
110
|
canvas.toBlob(
|
|
181
111
|
(b) => resolve(b),
|
|
@@ -200,14 +130,11 @@ export function CropDialog({
|
|
|
200
130
|
|
|
201
131
|
const handleUseOriginal = useCallback(() => onClose(file), [file, onClose])
|
|
202
132
|
const handleCancel = useCallback(() => onClose(null), [onClose])
|
|
203
|
-
const handleRotate = useCallback((
|
|
204
|
-
cropperRef.current?.rotateImage(
|
|
205
|
-
}, [])
|
|
206
|
-
const handleFlip = useCallback((axis: "h" | "v") => {
|
|
207
|
-
cropperRef.current?.flipImage(axis === "h", axis === "v")
|
|
133
|
+
const handleRotate = useCallback(() => {
|
|
134
|
+
cropperRef.current?.rotateImage(90)
|
|
208
135
|
}, [])
|
|
209
136
|
|
|
210
|
-
//
|
|
137
|
+
// ESC kapat, R rotate
|
|
211
138
|
useEffect(() => {
|
|
212
139
|
if (!open) return
|
|
213
140
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -224,7 +151,7 @@ export function CropDialog({
|
|
|
224
151
|
}
|
|
225
152
|
if (e.key === "r" || e.key === "R") {
|
|
226
153
|
e.preventDefault()
|
|
227
|
-
handleRotate(
|
|
154
|
+
handleRotate()
|
|
228
155
|
}
|
|
229
156
|
}
|
|
230
157
|
window.addEventListener("keydown", onKey)
|
|
@@ -240,22 +167,36 @@ export function CropDialog({
|
|
|
240
167
|
animate={{ opacity: 1 }}
|
|
241
168
|
exit={{ opacity: 0 }}
|
|
242
169
|
transition={{ duration: 0.2 }}
|
|
243
|
-
//
|
|
244
|
-
|
|
170
|
+
// Inline color — host app'in tema değişkenleri text-white
|
|
171
|
+
// utility'sini override etse bile child icon/text bu rengi
|
|
172
|
+
// inherit eder (rotate ikonu, chip metinleri vs).
|
|
173
|
+
style={{ color: "#ffffff" }}
|
|
174
|
+
className="fixed inset-0 z-[60] flex flex-col bg-black"
|
|
245
175
|
>
|
|
246
|
-
{/* Header */}
|
|
247
|
-
<
|
|
176
|
+
{/* Header — iOS Photos: Cancel sol / başlık orta / Done sağ */}
|
|
177
|
+
<header
|
|
178
|
+
className="flex items-center justify-between gap-3 px-4 py-3"
|
|
179
|
+
style={{
|
|
180
|
+
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
|
181
|
+
background: "rgba(0,0,0,0.4)",
|
|
182
|
+
backdropFilter: "blur(8px)",
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
248
185
|
<button
|
|
249
186
|
type="button"
|
|
250
187
|
onClick={handleCancel}
|
|
251
188
|
disabled={busy}
|
|
252
|
-
|
|
189
|
+
style={{ color: "rgba(255,255,255,0.85)" }}
|
|
190
|
+
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-white/10 disabled:opacity-50"
|
|
253
191
|
>
|
|
254
192
|
Cancel
|
|
255
193
|
</button>
|
|
256
194
|
<div className="flex min-w-0 flex-col items-center text-center">
|
|
257
|
-
<span className="text-sm font-semibold">Crop
|
|
258
|
-
<span
|
|
195
|
+
<span className="text-sm font-semibold">Crop</span>
|
|
196
|
+
<span
|
|
197
|
+
style={{ color: "rgba(255,255,255,0.5)" }}
|
|
198
|
+
className="truncate max-w-xs text-[11px]"
|
|
199
|
+
>
|
|
259
200
|
{file.name}
|
|
260
201
|
</span>
|
|
261
202
|
</div>
|
|
@@ -263,188 +204,106 @@ export function CropDialog({
|
|
|
263
204
|
type="button"
|
|
264
205
|
onClick={handleApply}
|
|
265
206
|
disabled={busy || tooLarge}
|
|
266
|
-
|
|
207
|
+
style={{ backgroundColor: "#fff", color: "#0a0a0a" }}
|
|
208
|
+
className="rounded-md px-3 py-1.5 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
267
209
|
>
|
|
268
|
-
{busy ? "Cropping…" : "
|
|
210
|
+
{busy ? "Cropping…" : "Done"}
|
|
269
211
|
</button>
|
|
270
|
-
</
|
|
212
|
+
</header>
|
|
271
213
|
|
|
272
|
-
{/*
|
|
273
|
-
<div className="
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
type="button"
|
|
279
|
-
onClick={() => setAspectId(p.id)}
|
|
280
|
-
className={cls(
|
|
281
|
-
"rounded-full px-3 py-1 text-xs transition-colors",
|
|
282
|
-
aspectId === p.id
|
|
283
|
-
? "bg-white text-black"
|
|
284
|
-
: "text-white/60 hover:bg-white/10 hover:text-white",
|
|
285
|
-
)}
|
|
286
|
-
>
|
|
287
|
-
{p.label}
|
|
288
|
-
</button>
|
|
289
|
-
))}
|
|
290
|
-
</div>
|
|
291
|
-
<div className="flex items-center gap-1">
|
|
292
|
-
<ToolbarIconButton
|
|
293
|
-
onClick={() => handleRotate(-90)}
|
|
294
|
-
title="Rotate left (Shift+R)"
|
|
295
|
-
ariaLabel="Rotate left"
|
|
296
|
-
>
|
|
297
|
-
<RotateLeftIcon />
|
|
298
|
-
</ToolbarIconButton>
|
|
299
|
-
<ToolbarIconButton
|
|
300
|
-
onClick={() => handleRotate(90)}
|
|
301
|
-
title="Rotate right (R)"
|
|
302
|
-
ariaLabel="Rotate right"
|
|
214
|
+
{/* Main: cropper full bleed */}
|
|
215
|
+
<div className="relative flex-1 min-h-0 bg-black">
|
|
216
|
+
{tooLarge ? (
|
|
217
|
+
<div
|
|
218
|
+
style={{ color: "rgba(255,255,255,0.7)" }}
|
|
219
|
+
className="flex h-full w-full items-center justify-center p-6 text-center text-sm"
|
|
303
220
|
>
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
ariaLabel="Flip vertical"
|
|
318
|
-
>
|
|
319
|
-
<FlipVerticalIcon />
|
|
320
|
-
</ToolbarIconButton>
|
|
321
|
-
</div>
|
|
221
|
+
Image too large to crop in browser. Upload as-is or resize
|
|
222
|
+
beforehand.
|
|
223
|
+
</div>
|
|
224
|
+
) : (
|
|
225
|
+
<Cropper
|
|
226
|
+
ref={cropperRef}
|
|
227
|
+
src={imageUrl}
|
|
228
|
+
stencilProps={{
|
|
229
|
+
aspectRatio: aspectRatio,
|
|
230
|
+
}}
|
|
231
|
+
className="sentroy-mobile-cropper"
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
322
234
|
</div>
|
|
323
235
|
|
|
324
|
-
{/*
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
236
|
+
{/* Bottom toolbar — aspect chips (sol) + rotate (sağ).
|
|
237
|
+
iOS Photos pattern. */}
|
|
238
|
+
<footer
|
|
239
|
+
className="flex items-center gap-2 px-3 py-3"
|
|
240
|
+
style={{
|
|
241
|
+
borderTop: "1px solid rgba(255,255,255,0.08)",
|
|
242
|
+
background: "rgba(0,0,0,0.4)",
|
|
243
|
+
backdropFilter: "blur(8px)",
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<div
|
|
247
|
+
className="flex flex-1 items-center gap-1 overflow-x-auto"
|
|
248
|
+
style={{ scrollbarWidth: "none" }}
|
|
249
|
+
>
|
|
250
|
+
{ASPECT_PRESETS.map((p) => {
|
|
251
|
+
const active = aspectId === p.id
|
|
252
|
+
return (
|
|
253
|
+
<button
|
|
254
|
+
key={p.id}
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={() => setAspectId(p.id)}
|
|
257
|
+
style={
|
|
258
|
+
active
|
|
259
|
+
? { backgroundColor: "#fff", color: "#0a0a0a" }
|
|
260
|
+
: { color: "rgba(255,255,255,0.7)" }
|
|
261
|
+
}
|
|
262
|
+
className={
|
|
263
|
+
"shrink-0 rounded-full px-3 py-1.5 text-xs transition-colors " +
|
|
264
|
+
(active ? "font-medium" : "hover:bg-white/10")
|
|
265
|
+
}
|
|
266
|
+
>
|
|
267
|
+
{p.label}
|
|
268
|
+
</button>
|
|
269
|
+
)
|
|
270
|
+
})}
|
|
351
271
|
</div>
|
|
272
|
+
<div
|
|
273
|
+
className="mx-2 hidden h-5 w-px md:block"
|
|
274
|
+
style={{ background: "rgba(255,255,255,0.15)" }}
|
|
275
|
+
/>
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
onClick={handleRotate}
|
|
279
|
+
title="Rotate (R)"
|
|
280
|
+
aria-label="Rotate"
|
|
281
|
+
style={{ color: "rgba(255,255,255,0.85)" }}
|
|
282
|
+
className="inline-flex shrink-0 size-9 items-center justify-center rounded-full transition-colors hover:bg-white/10"
|
|
283
|
+
>
|
|
284
|
+
<RotateIcon />
|
|
285
|
+
</button>
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
onClick={handleUseOriginal}
|
|
289
|
+
disabled={busy}
|
|
290
|
+
style={{ color: "rgba(255,255,255,0.6)" }}
|
|
291
|
+
className="hidden shrink-0 rounded-md px-3 py-1.5 text-[11px] transition-colors hover:bg-white/10 disabled:opacity-50 sm:inline-flex"
|
|
292
|
+
>
|
|
293
|
+
Use original
|
|
294
|
+
</button>
|
|
295
|
+
</footer>
|
|
352
296
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
Preview
|
|
358
|
-
</span>
|
|
359
|
-
{outputSize && (
|
|
360
|
-
<span className="font-mono text-[10px] text-white/40">
|
|
361
|
-
{outputSize.w}×{outputSize.h}
|
|
362
|
-
</span>
|
|
363
|
-
)}
|
|
364
|
-
</div>
|
|
365
|
-
<div className="flex min-h-[160px] items-center justify-center rounded-lg border border-white/10 bg-black/50 p-3">
|
|
366
|
-
{tooLarge ? (
|
|
367
|
-
<span className="text-[11px] text-white/50">
|
|
368
|
-
Preview unavailable
|
|
369
|
-
</span>
|
|
370
|
-
) : previewDataUrl ? (
|
|
371
|
-
/* eslint-disable-next-line @next/next/no-img-element */
|
|
372
|
-
<img
|
|
373
|
-
src={previewDataUrl}
|
|
374
|
-
alt="Crop preview"
|
|
375
|
-
className="max-h-[240px] max-w-full rounded-md object-contain shadow-md"
|
|
376
|
-
/>
|
|
377
|
-
) : (
|
|
378
|
-
<span className="text-[11px] text-white/40">
|
|
379
|
-
Adjust crop…
|
|
380
|
-
</span>
|
|
381
|
-
)}
|
|
382
|
-
</div>
|
|
383
|
-
<div className="flex flex-col gap-1.5 text-[11px] text-white/60">
|
|
384
|
-
<Stat
|
|
385
|
-
label="Aspect"
|
|
386
|
-
value={
|
|
387
|
-
ASPECT_PRESETS.find((p) => p.id === aspectId)?.label ??
|
|
388
|
-
"Free"
|
|
389
|
-
}
|
|
390
|
-
/>
|
|
391
|
-
{outputSize && (
|
|
392
|
-
<Stat
|
|
393
|
-
label="Output"
|
|
394
|
-
value={`${outputSize.w} × ${outputSize.h} px`}
|
|
395
|
-
/>
|
|
396
|
-
)}
|
|
397
|
-
<Stat
|
|
398
|
-
label="Format"
|
|
399
|
-
value={file.type === "image/png" ? "PNG" : "JPEG"}
|
|
400
|
-
/>
|
|
401
|
-
</div>
|
|
402
|
-
<button
|
|
403
|
-
type="button"
|
|
404
|
-
onClick={handleUseOriginal}
|
|
405
|
-
disabled={busy}
|
|
406
|
-
className="mt-auto w-full rounded-md border border-white/20 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
|
|
407
|
-
>
|
|
408
|
-
Use original (skip crop)
|
|
409
|
-
</button>
|
|
410
|
-
</aside>
|
|
411
|
-
</div>
|
|
412
|
-
|
|
413
|
-
{/* Local cropper styles — paket default'unu Sentroy paletine
|
|
414
|
-
align ediyoruz. Stencil border ve grid çizgisini ince-keskin
|
|
415
|
-
tutuyoruz (iOS Photos benzeri). */}
|
|
297
|
+
{/* Cropper container sizing — paketin kendi style.css'i çoğu
|
|
298
|
+
görseli sağlar; bizim sadece full-bleed boyutlama. Cropper'ın
|
|
299
|
+
ana rengini (stencil border + handler accent) `color`
|
|
300
|
+
property'si üzerinden geçir; root'a beyaz set ettik. */}
|
|
416
301
|
<style>{`
|
|
417
|
-
.sentroy-cropper {
|
|
302
|
+
.sentroy-mobile-cropper {
|
|
418
303
|
height: 100%;
|
|
419
304
|
width: 100%;
|
|
420
305
|
background: #000;
|
|
421
306
|
}
|
|
422
|
-
.sentroy-cropper-background {
|
|
423
|
-
background-color: rgba(0, 0, 0, 0.7);
|
|
424
|
-
}
|
|
425
|
-
.sentroy-cropper .advanced-cropper-stencil-overlay {
|
|
426
|
-
background: rgba(0, 0, 0, 0.55);
|
|
427
|
-
}
|
|
428
|
-
.sentroy-cropper .advanced-cropper-rectangle-stencil__draggable-area {
|
|
429
|
-
border: 1px solid rgba(255, 255, 255, 0.95);
|
|
430
|
-
}
|
|
431
|
-
.sentroy-cropper .advanced-cropper-line-wrapper {
|
|
432
|
-
background: rgba(255, 255, 255, 0.95);
|
|
433
|
-
}
|
|
434
|
-
.sentroy-cropper .advanced-cropper-handler-wrapper--west-north,
|
|
435
|
-
.sentroy-cropper .advanced-cropper-handler-wrapper--north-east,
|
|
436
|
-
.sentroy-cropper .advanced-cropper-handler-wrapper--east-south,
|
|
437
|
-
.sentroy-cropper .advanced-cropper-handler-wrapper--south-west {
|
|
438
|
-
width: 22px;
|
|
439
|
-
height: 22px;
|
|
440
|
-
}
|
|
441
|
-
.sentroy-cropper .advanced-cropper-handler-wrapper__draggable {
|
|
442
|
-
background: #fff;
|
|
443
|
-
border: 2px solid #000;
|
|
444
|
-
width: 12px;
|
|
445
|
-
height: 12px;
|
|
446
|
-
border-radius: 2px;
|
|
447
|
-
}
|
|
448
307
|
`}</style>
|
|
449
308
|
</motion.div>
|
|
450
309
|
)}
|
|
@@ -452,44 +311,7 @@ export function CropDialog({
|
|
|
452
311
|
)
|
|
453
312
|
}
|
|
454
313
|
|
|
455
|
-
function
|
|
456
|
-
return (
|
|
457
|
-
<div className="flex items-center justify-between">
|
|
458
|
-
<span>{label}</span>
|
|
459
|
-
<span className="font-mono text-white/80">{value}</span>
|
|
460
|
-
</div>
|
|
461
|
-
)
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function ToolbarIconButton({
|
|
465
|
-
onClick,
|
|
466
|
-
title,
|
|
467
|
-
ariaLabel,
|
|
468
|
-
children,
|
|
469
|
-
}: {
|
|
470
|
-
onClick: () => void
|
|
471
|
-
title: string
|
|
472
|
-
ariaLabel: string
|
|
473
|
-
children: React.ReactNode
|
|
474
|
-
}) {
|
|
475
|
-
return (
|
|
476
|
-
<button
|
|
477
|
-
type="button"
|
|
478
|
-
onClick={onClick}
|
|
479
|
-
title={title}
|
|
480
|
-
aria-label={ariaLabel}
|
|
481
|
-
className="inline-flex size-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
|
482
|
-
>
|
|
483
|
-
{children}
|
|
484
|
-
</button>
|
|
485
|
-
)
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function cls(...arr: Array<string | false | null | undefined>): string {
|
|
489
|
-
return arr.filter(Boolean).join(" ")
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function RotateLeftIcon() {
|
|
314
|
+
function RotateIcon() {
|
|
493
315
|
return (
|
|
494
316
|
<svg
|
|
495
317
|
viewBox="0 0 24 24"
|
|
@@ -498,25 +320,7 @@ function RotateLeftIcon() {
|
|
|
498
320
|
strokeWidth="2"
|
|
499
321
|
strokeLinecap="round"
|
|
500
322
|
strokeLinejoin="round"
|
|
501
|
-
className="size-
|
|
502
|
-
aria-hidden="true"
|
|
503
|
-
>
|
|
504
|
-
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
|
505
|
-
<path d="M3 4v5h5" />
|
|
506
|
-
</svg>
|
|
507
|
-
)
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function RotateRightIcon() {
|
|
511
|
-
return (
|
|
512
|
-
<svg
|
|
513
|
-
viewBox="0 0 24 24"
|
|
514
|
-
fill="none"
|
|
515
|
-
stroke="currentColor"
|
|
516
|
-
strokeWidth="2"
|
|
517
|
-
strokeLinecap="round"
|
|
518
|
-
strokeLinejoin="round"
|
|
519
|
-
className="size-4"
|
|
323
|
+
className="size-5"
|
|
520
324
|
aria-hidden="true"
|
|
521
325
|
>
|
|
522
326
|
<path d="M21 12a9 9 0 1 1-3-6.7" />
|
|
@@ -524,41 +328,3 @@ function RotateRightIcon() {
|
|
|
524
328
|
</svg>
|
|
525
329
|
)
|
|
526
330
|
}
|
|
527
|
-
|
|
528
|
-
function FlipHorizontalIcon() {
|
|
529
|
-
return (
|
|
530
|
-
<svg
|
|
531
|
-
viewBox="0 0 24 24"
|
|
532
|
-
fill="none"
|
|
533
|
-
stroke="currentColor"
|
|
534
|
-
strokeWidth="2"
|
|
535
|
-
strokeLinecap="round"
|
|
536
|
-
strokeLinejoin="round"
|
|
537
|
-
className="size-4"
|
|
538
|
-
aria-hidden="true"
|
|
539
|
-
>
|
|
540
|
-
<path d="M12 3v18" />
|
|
541
|
-
<path d="M16 7l4 5-4 5" />
|
|
542
|
-
<path d="M8 7l-4 5 4 5" />
|
|
543
|
-
</svg>
|
|
544
|
-
)
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function FlipVerticalIcon() {
|
|
548
|
-
return (
|
|
549
|
-
<svg
|
|
550
|
-
viewBox="0 0 24 24"
|
|
551
|
-
fill="none"
|
|
552
|
-
stroke="currentColor"
|
|
553
|
-
strokeWidth="2"
|
|
554
|
-
strokeLinecap="round"
|
|
555
|
-
strokeLinejoin="round"
|
|
556
|
-
className="size-4"
|
|
557
|
-
aria-hidden="true"
|
|
558
|
-
>
|
|
559
|
-
<path d="M3 12h18" />
|
|
560
|
-
<path d="M7 8l5-4 5 4" />
|
|
561
|
-
<path d="M7 16l5 4 5-4" />
|
|
562
|
-
</svg>
|
|
563
|
-
)
|
|
564
|
-
}
|