@sentroy-co/client-sdk 2.13.1 → 2.13.2
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.
|
@@ -1,38 +1,35 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
2
|
-
import Cropper from "react-
|
|
2
|
+
import { Cropper, type CropperRef } from "react-advanced-cropper"
|
|
3
3
|
import { motion, AnimatePresence } from "motion/react"
|
|
4
|
+
import "react-advanced-cropper/dist/style.css"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Image crop dialog —
|
|
7
|
+
* Image crop dialog — iOS Photos benzeri full-screen crop UI. Önceki
|
|
8
|
+
* `react-easy-crop` implementation'ı drag UX ve preview render
|
|
9
|
+
* tarafında zayıftı; `react-advanced-cropper` daha modern stencil
|
|
10
|
+
* sistemi + native-feel pinch/zoom verir.
|
|
11
|
+
*
|
|
12
|
+
* Akış (storage upload pipeline'ından preprocess hook):
|
|
7
13
|
* - Aspect preset toolbar (1:1, 4:3, 16:9, 3:2, 9:16, Free)
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - Output pixel boyutu (örn. 1200×800) — RP'lerin export presetlerine
|
|
15
|
-
* hizalanmak için.
|
|
16
|
-
* - Apply → cropped Blob, Cancel → null, "Use original" → original File.
|
|
14
|
+
* - Rotate 90° CW/CCW butonları (R / Shift+R)
|
|
15
|
+
* - Cropper'ın `getCanvas()` çıktısından **live preview**
|
|
16
|
+
* thumbnail — kullanıcı Apply'a basmadan sonucu görür.
|
|
17
|
+
* - Output pixel boyutu (örn. 1200×800) read-out.
|
|
18
|
+
* - Apply: ref üzerinden `getCanvas().toBlob` → File.
|
|
19
|
+
* - "Use original": orijinal File döner; Cancel: null.
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
21
|
+
* Tam ekran: `inset-0`, scrim'siz; consent flow gibi destination-only UX —
|
|
22
|
+
* dialog her şeyi kaplar, dikkat dağıtmaz.
|
|
20
23
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* react-easy-crop'un `croppedAreaPixels` çıktısı zaten rotation'a göre
|
|
24
|
-
* transform edilmiş — biz canvas'a aynı rotation'ı uygulayıp aynı koordinat
|
|
25
|
-
* sisteminde drawImage yapıyoruz.
|
|
24
|
+
* Lazy subpath (`@sentroy-co/client-sdk/react/crop`) — ana SDK import'u
|
|
25
|
+
* cropper bundle'ı yutmasın.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }> = [
|
|
28
|
+
const ASPECT_PRESETS: Array<{
|
|
29
|
+
id: string
|
|
30
|
+
label: string
|
|
31
|
+
aspect: number | null
|
|
32
|
+
}> = [
|
|
36
33
|
{ id: "free", label: "Free", aspect: null },
|
|
37
34
|
{ id: "1:1", label: "1:1", aspect: 1 },
|
|
38
35
|
{ id: "16:9", label: "16:9", aspect: 16 / 9 },
|
|
@@ -42,7 +39,7 @@ const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }
|
|
|
42
39
|
]
|
|
43
40
|
|
|
44
41
|
const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP — üstü tarayıcı memory peak'i riskli
|
|
45
|
-
const PREVIEW_MAX_DIM =
|
|
42
|
+
const PREVIEW_MAX_DIM = 240
|
|
46
43
|
|
|
47
44
|
export interface CropDialogProps {
|
|
48
45
|
open: boolean
|
|
@@ -67,99 +64,124 @@ export function CropDialog({
|
|
|
67
64
|
}: CropDialogProps) {
|
|
68
65
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
|
69
66
|
const [aspectId, setAspectId] = useState(defaultAspect)
|
|
70
|
-
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
|
71
|
-
const [zoom, setZoom] = useState(1)
|
|
72
|
-
const [rotation, setRotation] = useState(0)
|
|
73
|
-
const [croppedAreaPixels, setCroppedAreaPixels] = useState<CropArea | null>(null)
|
|
74
67
|
const [busy, setBusy] = useState(false)
|
|
75
68
|
const [tooLarge, setTooLarge] = useState(false)
|
|
76
|
-
const
|
|
69
|
+
const [outputSize, setOutputSize] = useState<{ w: number; h: number } | null>(
|
|
70
|
+
null,
|
|
71
|
+
)
|
|
72
|
+
const [previewDataUrl, setPreviewDataUrl] = useState<string | null>(null)
|
|
73
|
+
|
|
74
|
+
const cropperRef = useRef<CropperRef | null>(null)
|
|
77
75
|
const previewRafRef = useRef<number | null>(null)
|
|
78
|
-
const imageElRef = useRef<HTMLImageElement | null>(null)
|
|
79
76
|
|
|
80
77
|
// Object URL lifecycle
|
|
81
78
|
useEffect(() => {
|
|
82
79
|
if (!open) return
|
|
83
80
|
const url = URL.createObjectURL(file)
|
|
84
81
|
setImageUrl(url)
|
|
85
|
-
setCrop({ x: 0, y: 0 })
|
|
86
|
-
setZoom(1)
|
|
87
|
-
setRotation(0)
|
|
88
82
|
setAspectId(defaultAspect)
|
|
89
83
|
setTooLarge(false)
|
|
90
|
-
|
|
84
|
+
setOutputSize(null)
|
|
85
|
+
setPreviewDataUrl(null)
|
|
86
|
+
// Pixel guard — large image decode tarayıcıyı çökertir
|
|
91
87
|
const img = new Image()
|
|
92
88
|
img.onload = () => {
|
|
93
89
|
if (img.naturalWidth * img.naturalHeight > MAX_PIXEL_GUARD) {
|
|
94
90
|
setTooLarge(true)
|
|
95
91
|
}
|
|
96
|
-
imageElRef.current = img
|
|
97
92
|
}
|
|
98
93
|
img.src = url
|
|
99
94
|
return () => {
|
|
100
95
|
URL.revokeObjectURL(url)
|
|
101
|
-
|
|
96
|
+
if (previewRafRef.current !== null) {
|
|
97
|
+
cancelAnimationFrame(previewRafRef.current)
|
|
98
|
+
previewRafRef.current = null
|
|
99
|
+
}
|
|
102
100
|
}
|
|
103
101
|
}, [open, file, defaultAspect])
|
|
104
102
|
|
|
105
|
-
const
|
|
106
|
-
(
|
|
107
|
-
setCroppedAreaPixels(areaPixels)
|
|
108
|
-
},
|
|
109
|
-
[],
|
|
110
|
-
)
|
|
103
|
+
const aspectRatio =
|
|
104
|
+
ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
|
|
111
105
|
|
|
112
|
-
//
|
|
113
|
-
// güncelle.
|
|
114
|
-
// canvas çizmek pahalı).
|
|
106
|
+
// Aspect preset değişirse cropper coordinates'ini yeni aspect'e göre
|
|
107
|
+
// güncelle. `setCoordinates` ile aspect'i zorla.
|
|
115
108
|
useEffect(() => {
|
|
116
|
-
if (!
|
|
109
|
+
if (!cropperRef.current) return
|
|
110
|
+
if (aspectRatio === undefined) return
|
|
111
|
+
const state = cropperRef.current.getState()
|
|
112
|
+
if (!state) return
|
|
113
|
+
const { coordinates } = state
|
|
114
|
+
if (!coordinates) return
|
|
115
|
+
const current = coordinates.width / coordinates.height
|
|
116
|
+
if (Math.abs(current - aspectRatio) < 0.001) return
|
|
117
|
+
// Aspect ratio'ya snap — width'i koru, height'i hesapla
|
|
118
|
+
const newWidth = coordinates.width
|
|
119
|
+
const newHeight = coordinates.width / aspectRatio
|
|
120
|
+
cropperRef.current.setCoordinates({
|
|
121
|
+
width: newWidth,
|
|
122
|
+
height: newHeight,
|
|
123
|
+
})
|
|
124
|
+
}, [aspectRatio, aspectId])
|
|
125
|
+
|
|
126
|
+
// Live preview render — cropper change event'inde getCanvas() ile
|
|
127
|
+
// küçük thumbnail üret. RAF ile throttle.
|
|
128
|
+
const renderPreview = useCallback(() => {
|
|
117
129
|
if (previewRafRef.current !== null) {
|
|
118
130
|
cancelAnimationFrame(previewRafRef.current)
|
|
119
131
|
}
|
|
120
132
|
previewRafRef.current = requestAnimationFrame(() => {
|
|
121
|
-
const canvas = previewCanvasRef.current
|
|
122
|
-
const img = imageElRef.current
|
|
123
|
-
if (!canvas || !img) return
|
|
124
|
-
const area = croppedAreaPixels
|
|
125
|
-
// Preview boyutu — aspect korunarak max edge PREVIEW_MAX_DIM
|
|
126
|
-
const scale =
|
|
127
|
-
area.width >= area.height
|
|
128
|
-
? PREVIEW_MAX_DIM / area.width
|
|
129
|
-
: PREVIEW_MAX_DIM / area.height
|
|
130
|
-
const pw = Math.max(1, Math.round(area.width * scale))
|
|
131
|
-
const ph = Math.max(1, Math.round(area.height * scale))
|
|
132
|
-
canvas.width = pw
|
|
133
|
-
canvas.height = ph
|
|
134
|
-
const ctx = canvas.getContext("2d")
|
|
135
|
-
if (!ctx) return
|
|
136
|
-
ctx.imageSmoothingQuality = "high"
|
|
137
|
-
ctx.clearRect(0, 0, pw, ph)
|
|
138
|
-
drawRotatedCrop(ctx, img, area, rotation, pw, ph)
|
|
139
133
|
previewRafRef.current = null
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
134
|
+
const cropper = cropperRef.current
|
|
135
|
+
if (!cropper) return
|
|
136
|
+
const canvas = cropper.getCanvas({
|
|
137
|
+
// Preview canvas — düşük boyut, smooth (output quality değil)
|
|
138
|
+
maxWidth: PREVIEW_MAX_DIM * 2,
|
|
139
|
+
maxHeight: PREVIEW_MAX_DIM * 2,
|
|
140
|
+
imageSmoothingQuality: "medium",
|
|
141
|
+
})
|
|
142
|
+
if (!canvas) return
|
|
143
|
+
setOutputSize({ w: canvas.width, h: canvas.height })
|
|
144
|
+
// Data URL — küçük canvas; performant
|
|
145
|
+
try {
|
|
146
|
+
setPreviewDataUrl(canvas.toDataURL("image/jpeg", 0.7))
|
|
147
|
+
} catch {
|
|
148
|
+
// toDataURL nadir tainted-canvas durumunda fail edebilir; sessiz geç
|
|
145
149
|
}
|
|
146
|
-
}
|
|
147
|
-
}, [
|
|
150
|
+
})
|
|
151
|
+
}, [])
|
|
148
152
|
|
|
149
|
-
const
|
|
150
|
-
|
|
153
|
+
const handleCropperChange = useCallback(() => {
|
|
154
|
+
renderPreview()
|
|
155
|
+
}, [renderPreview])
|
|
156
|
+
|
|
157
|
+
const handleCropperReady = useCallback(() => {
|
|
158
|
+
renderPreview()
|
|
159
|
+
}, [renderPreview])
|
|
151
160
|
|
|
152
161
|
const handleApply = useCallback(async () => {
|
|
153
|
-
|
|
162
|
+
const cropper = cropperRef.current
|
|
163
|
+
if (!cropper) return
|
|
154
164
|
setBusy(true)
|
|
155
165
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
166
|
+
const canvas = cropper.getCanvas({
|
|
167
|
+
imageSmoothingQuality: "high",
|
|
168
|
+
})
|
|
169
|
+
if (!canvas) {
|
|
170
|
+
setBusy(false)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
const outputMime = file.type === "image/png" ? "image/png" : "image/jpeg"
|
|
174
|
+
const blob = await new Promise<Blob | null>((resolve) => {
|
|
175
|
+
canvas.toBlob(
|
|
176
|
+
(b) => resolve(b),
|
|
177
|
+
outputMime,
|
|
178
|
+
outputMime === "image/jpeg" ? outputQuality : undefined,
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
if (!blob) {
|
|
182
|
+
setBusy(false)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
163
185
|
const ext = blob.type === "image/png" ? "png" : "jpg"
|
|
164
186
|
const baseName = file.name.replace(/\.[^.]+$/, "")
|
|
165
187
|
const cropped = new File([blob], `${baseName}.${ext}`, {
|
|
@@ -169,34 +191,33 @@ export function CropDialog({
|
|
|
169
191
|
} finally {
|
|
170
192
|
setBusy(false)
|
|
171
193
|
}
|
|
172
|
-
}, [
|
|
194
|
+
}, [file, onClose, outputQuality])
|
|
173
195
|
|
|
174
196
|
const handleUseOriginal = useCallback(() => onClose(file), [file, onClose])
|
|
175
197
|
const handleCancel = useCallback(() => onClose(null), [onClose])
|
|
176
|
-
const handleRotate = useCallback(
|
|
177
|
-
(delta
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
},
|
|
183
|
-
[],
|
|
184
|
-
)
|
|
198
|
+
const handleRotate = useCallback((delta: 90 | -90) => {
|
|
199
|
+
cropperRef.current?.rotateImage(delta)
|
|
200
|
+
}, [])
|
|
201
|
+
const handleFlip = useCallback((axis: "h" | "v") => {
|
|
202
|
+
cropperRef.current?.flipImage(axis === "h", axis === "v")
|
|
203
|
+
}, [])
|
|
185
204
|
|
|
186
|
-
// ESC
|
|
205
|
+
// Keyboard shortcuts — ESC kapat, R rotate, F flip
|
|
187
206
|
useEffect(() => {
|
|
188
207
|
if (!open) return
|
|
189
208
|
const onKey = (e: KeyboardEvent) => {
|
|
209
|
+
if (
|
|
210
|
+
e.target instanceof HTMLElement &&
|
|
211
|
+
(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
|
212
|
+
) {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
190
215
|
if (e.key === "Escape") {
|
|
191
216
|
e.stopPropagation()
|
|
192
217
|
handleCancel()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
|
197
|
-
) {
|
|
198
|
-
return
|
|
199
|
-
}
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (e.key === "r" || e.key === "R") {
|
|
200
221
|
e.preventDefault()
|
|
201
222
|
handleRotate(e.shiftKey ? -90 : 90)
|
|
202
223
|
}
|
|
@@ -205,13 +226,6 @@ export function CropDialog({
|
|
|
205
226
|
return () => window.removeEventListener("keydown", onKey)
|
|
206
227
|
}, [open, handleCancel, handleRotate])
|
|
207
228
|
|
|
208
|
-
const outputWidth = croppedAreaPixels ? Math.round(croppedAreaPixels.width) : 0
|
|
209
|
-
const outputHeight = croppedAreaPixels ? Math.round(croppedAreaPixels.height) : 0
|
|
210
|
-
// Rotation 90° / 270° iken output dimensions swap edilir (canvas rotate
|
|
211
|
-
// sonrası kullanıcı görsel olarak swap görür).
|
|
212
|
-
const displayW = rotation % 180 === 0 ? outputWidth : outputHeight
|
|
213
|
-
const displayH = rotation % 180 === 0 ? outputHeight : outputWidth
|
|
214
|
-
|
|
215
229
|
return (
|
|
216
230
|
<AnimatePresence>
|
|
217
231
|
{open && imageUrl && (
|
|
@@ -221,334 +235,249 @@ export function CropDialog({
|
|
|
221
235
|
animate={{ opacity: 1 }}
|
|
222
236
|
exit={{ opacity: 0 }}
|
|
223
237
|
transition={{ duration: 0.2 }}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (e.target === e.currentTarget) handleCancel()
|
|
227
|
-
}}
|
|
238
|
+
// Tam ekran — scrim değil, kendisi background; iOS Photos UX
|
|
239
|
+
className="fixed inset-0 z-[60] flex flex-col bg-black text-white"
|
|
228
240
|
>
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
</
|
|
244
|
-
<button
|
|
245
|
-
type="button"
|
|
246
|
-
onClick={handleCancel}
|
|
247
|
-
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
|
248
|
-
aria-label="Cancel"
|
|
249
|
-
>
|
|
250
|
-
<svg
|
|
251
|
-
viewBox="0 0 24 24"
|
|
252
|
-
fill="none"
|
|
253
|
-
stroke="currentColor"
|
|
254
|
-
strokeWidth="2"
|
|
255
|
-
strokeLinecap="round"
|
|
256
|
-
strokeLinejoin="round"
|
|
257
|
-
className="size-4"
|
|
258
|
-
>
|
|
259
|
-
<path d="M18 6 6 18M6 6l12 12" />
|
|
260
|
-
</svg>
|
|
261
|
-
</button>
|
|
241
|
+
{/* Header */}
|
|
242
|
+
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-black/40 px-4 py-3 backdrop-blur-sm">
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={handleCancel}
|
|
246
|
+
disabled={busy}
|
|
247
|
+
className="rounded-md px-3 py-1.5 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
|
|
248
|
+
>
|
|
249
|
+
Cancel
|
|
250
|
+
</button>
|
|
251
|
+
<div className="flex min-w-0 flex-col items-center text-center">
|
|
252
|
+
<span className="text-sm font-semibold">Crop image</span>
|
|
253
|
+
<span className="truncate max-w-xs text-[11px] text-white/50">
|
|
254
|
+
{file.name}
|
|
255
|
+
</span>
|
|
262
256
|
</div>
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={handleApply}
|
|
260
|
+
disabled={busy || tooLarge}
|
|
261
|
+
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
262
|
+
>
|
|
263
|
+
{busy ? "Cropping…" : "Apply"}
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
263
266
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
<button
|
|
269
|
-
key={p.id}
|
|
270
|
-
type="button"
|
|
271
|
-
onClick={() => setAspectId(p.id)}
|
|
272
|
-
className={cls(
|
|
273
|
-
"rounded-md px-2.5 py-1 text-xs transition-colors",
|
|
274
|
-
aspectId === p.id
|
|
275
|
-
? "bg-foreground text-background"
|
|
276
|
-
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
277
|
-
)}
|
|
278
|
-
>
|
|
279
|
-
{p.label}
|
|
280
|
-
</button>
|
|
281
|
-
))}
|
|
282
|
-
</div>
|
|
283
|
-
<div className="ms-auto flex items-center gap-1">
|
|
267
|
+
{/* Aspect + rotate toolbar */}
|
|
268
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 bg-black/30 px-3 py-2">
|
|
269
|
+
<div className="flex flex-1 flex-wrap items-center gap-1">
|
|
270
|
+
{ASPECT_PRESETS.map((p) => (
|
|
284
271
|
<button
|
|
272
|
+
key={p.id}
|
|
285
273
|
type="button"
|
|
286
|
-
onClick={() =>
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
<button
|
|
294
|
-
type="button"
|
|
295
|
-
onClick={() => handleRotate(90)}
|
|
296
|
-
title="Rotate right (R)"
|
|
297
|
-
aria-label="Rotate right"
|
|
298
|
-
className="rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
|
274
|
+
onClick={() => setAspectId(p.id)}
|
|
275
|
+
className={cls(
|
|
276
|
+
"rounded-full px-3 py-1 text-xs transition-colors",
|
|
277
|
+
aspectId === p.id
|
|
278
|
+
? "bg-white text-black"
|
|
279
|
+
: "text-white/60 hover:bg-white/10 hover:text-white",
|
|
280
|
+
)}
|
|
299
281
|
>
|
|
300
|
-
|
|
282
|
+
{p.label}
|
|
301
283
|
</button>
|
|
302
|
-
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
<div className="flex items-center gap-1">
|
|
287
|
+
<ToolbarIconButton
|
|
288
|
+
onClick={() => handleRotate(-90)}
|
|
289
|
+
title="Rotate left (Shift+R)"
|
|
290
|
+
ariaLabel="Rotate left"
|
|
291
|
+
>
|
|
292
|
+
<RotateLeftIcon />
|
|
293
|
+
</ToolbarIconButton>
|
|
294
|
+
<ToolbarIconButton
|
|
295
|
+
onClick={() => handleRotate(90)}
|
|
296
|
+
title="Rotate right (R)"
|
|
297
|
+
ariaLabel="Rotate right"
|
|
298
|
+
>
|
|
299
|
+
<RotateRightIcon />
|
|
300
|
+
</ToolbarIconButton>
|
|
301
|
+
<span className="mx-1 h-5 w-px bg-white/15" />
|
|
302
|
+
<ToolbarIconButton
|
|
303
|
+
onClick={() => handleFlip("h")}
|
|
304
|
+
title="Flip horizontal"
|
|
305
|
+
ariaLabel="Flip horizontal"
|
|
306
|
+
>
|
|
307
|
+
<FlipHorizontalIcon />
|
|
308
|
+
</ToolbarIconButton>
|
|
309
|
+
<ToolbarIconButton
|
|
310
|
+
onClick={() => handleFlip("v")}
|
|
311
|
+
title="Flip vertical"
|
|
312
|
+
ariaLabel="Flip vertical"
|
|
313
|
+
>
|
|
314
|
+
<FlipVerticalIcon />
|
|
315
|
+
</ToolbarIconButton>
|
|
303
316
|
</div>
|
|
317
|
+
</div>
|
|
304
318
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
319
|
+
{/* Main: cropper + side panel */}
|
|
320
|
+
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
|
321
|
+
{/* Cropper stage */}
|
|
322
|
+
<div className="relative flex-1 bg-black">
|
|
323
|
+
{tooLarge ? (
|
|
324
|
+
<div className="flex h-full w-full items-center justify-center p-6 text-center text-sm text-white/70">
|
|
325
|
+
Image too large to crop in browser. Upload as-is or resize
|
|
326
|
+
beforehand.
|
|
327
|
+
</div>
|
|
328
|
+
) : (
|
|
329
|
+
<Cropper
|
|
330
|
+
ref={cropperRef}
|
|
331
|
+
src={imageUrl}
|
|
332
|
+
className="sentroy-cropper"
|
|
333
|
+
// Stencil props — aspect lock + iOS-like rect stencil grid
|
|
334
|
+
stencilProps={{
|
|
335
|
+
aspectRatio: aspectRatio,
|
|
336
|
+
grid: true,
|
|
337
|
+
movable: true,
|
|
338
|
+
resizable: true,
|
|
339
|
+
}}
|
|
340
|
+
// Background overlay'i koyu yap (image dışı kalan kısım)
|
|
341
|
+
backgroundClassName="sentroy-cropper-background"
|
|
342
|
+
onChange={handleCropperChange}
|
|
343
|
+
onReady={handleCropperReady}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{/* Side panel: live preview + readout */}
|
|
349
|
+
<aside className="flex w-full shrink-0 flex-col gap-4 border-t border-white/10 bg-black/30 p-4 md:w-72 md:border-l md:border-t-0">
|
|
350
|
+
<div className="flex items-center justify-between">
|
|
351
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-white/50">
|
|
352
|
+
Preview
|
|
353
|
+
</span>
|
|
354
|
+
{outputSize && (
|
|
355
|
+
<span className="font-mono text-[10px] text-white/40">
|
|
356
|
+
{outputSize.w}×{outputSize.h}
|
|
357
|
+
</span>
|
|
328
358
|
)}
|
|
329
359
|
</div>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
335
|
-
Preview
|
|
360
|
+
<div className="flex min-h-[160px] items-center justify-center rounded-lg border border-white/10 bg-black/50 p-3">
|
|
361
|
+
{tooLarge ? (
|
|
362
|
+
<span className="text-[11px] text-white/50">
|
|
363
|
+
Preview unavailable
|
|
336
364
|
</span>
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
<canvas
|
|
350
|
-
ref={previewCanvasRef}
|
|
351
|
-
className="max-h-[220px] max-w-full rounded-sm shadow-sm"
|
|
352
|
-
/>
|
|
353
|
-
) : (
|
|
354
|
-
<span className="text-[11px] text-muted-foreground">
|
|
355
|
-
Adjust crop…
|
|
356
|
-
</span>
|
|
357
|
-
)}
|
|
358
|
-
</div>
|
|
359
|
-
<div className="flex flex-col gap-1 text-[11px] text-muted-foreground">
|
|
360
|
-
<div className="flex items-center justify-between">
|
|
361
|
-
<span>Aspect</span>
|
|
362
|
-
<span className="font-mono">
|
|
363
|
-
{ASPECT_PRESETS.find((p) => p.id === aspectId)?.label ??
|
|
364
|
-
"Free"}
|
|
365
|
-
</span>
|
|
366
|
-
</div>
|
|
367
|
-
<div className="flex items-center justify-between">
|
|
368
|
-
<span>Rotation</span>
|
|
369
|
-
<span className="font-mono">{rotation}°</span>
|
|
370
|
-
</div>
|
|
371
|
-
<div className="flex items-center justify-between">
|
|
372
|
-
<span>Zoom</span>
|
|
373
|
-
<span className="font-mono">{zoom.toFixed(2)}×</span>
|
|
374
|
-
</div>
|
|
375
|
-
</div>
|
|
365
|
+
) : previewDataUrl ? (
|
|
366
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
367
|
+
<img
|
|
368
|
+
src={previewDataUrl}
|
|
369
|
+
alt="Crop preview"
|
|
370
|
+
className="max-h-[240px] max-w-full rounded-md object-contain shadow-md"
|
|
371
|
+
/>
|
|
372
|
+
) : (
|
|
373
|
+
<span className="text-[11px] text-white/40">
|
|
374
|
+
Adjust crop…
|
|
375
|
+
</span>
|
|
376
|
+
)}
|
|
376
377
|
</div>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
−
|
|
390
|
-
</button>
|
|
391
|
-
<input
|
|
392
|
-
type="range"
|
|
393
|
-
min={1}
|
|
394
|
-
max={3}
|
|
395
|
-
step={0.05}
|
|
396
|
-
value={zoom}
|
|
397
|
-
onChange={(e) => setZoom(Number(e.target.value))}
|
|
398
|
-
className="flex-1 accent-foreground"
|
|
378
|
+
<div className="flex flex-col gap-1.5 text-[11px] text-white/60">
|
|
379
|
+
<Stat
|
|
380
|
+
label="Aspect"
|
|
381
|
+
value={
|
|
382
|
+
ASPECT_PRESETS.find((p) => p.id === aspectId)?.label ??
|
|
383
|
+
"Free"
|
|
384
|
+
}
|
|
385
|
+
/>
|
|
386
|
+
{outputSize && (
|
|
387
|
+
<Stat
|
|
388
|
+
label="Output"
|
|
389
|
+
value={`${outputSize.w} × ${outputSize.h} px`}
|
|
399
390
|
/>
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
+
|
|
406
|
-
</button>
|
|
407
|
-
<button
|
|
408
|
-
type="button"
|
|
409
|
-
onClick={() => {
|
|
410
|
-
setZoom(1)
|
|
411
|
-
setRotation(0)
|
|
412
|
-
setCrop({ x: 0, y: 0 })
|
|
413
|
-
}}
|
|
414
|
-
className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted/50"
|
|
415
|
-
>
|
|
416
|
-
Reset
|
|
417
|
-
</button>
|
|
418
|
-
</div>
|
|
419
|
-
)}
|
|
420
|
-
<div className="flex items-center justify-end gap-2">
|
|
421
|
-
<button
|
|
422
|
-
type="button"
|
|
423
|
-
onClick={handleCancel}
|
|
424
|
-
disabled={busy}
|
|
425
|
-
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted/50"
|
|
426
|
-
>
|
|
427
|
-
Cancel
|
|
428
|
-
</button>
|
|
429
|
-
<button
|
|
430
|
-
type="button"
|
|
431
|
-
onClick={handleUseOriginal}
|
|
432
|
-
disabled={busy}
|
|
433
|
-
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted/50"
|
|
434
|
-
>
|
|
435
|
-
Use original
|
|
436
|
-
</button>
|
|
437
|
-
<button
|
|
438
|
-
type="button"
|
|
439
|
-
onClick={handleApply}
|
|
440
|
-
disabled={busy || tooLarge || !croppedAreaPixels}
|
|
441
|
-
className="rounded-md bg-foreground px-3 py-1.5 text-xs font-medium text-background hover:opacity-90 disabled:opacity-50"
|
|
442
|
-
>
|
|
443
|
-
{busy ? "Cropping…" : "Apply crop"}
|
|
444
|
-
</button>
|
|
391
|
+
)}
|
|
392
|
+
<Stat
|
|
393
|
+
label="Format"
|
|
394
|
+
value={file.type === "image/png" ? "PNG" : "JPEG"}
|
|
395
|
+
/>
|
|
445
396
|
</div>
|
|
446
|
-
|
|
447
|
-
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={handleUseOriginal}
|
|
400
|
+
disabled={busy}
|
|
401
|
+
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"
|
|
402
|
+
>
|
|
403
|
+
Use original (skip crop)
|
|
404
|
+
</button>
|
|
405
|
+
</aside>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{/* Local cropper styles — paket default'unu Sentroy paletine
|
|
409
|
+
align ediyoruz. Stencil border ve grid çizgisini ince-keskin
|
|
410
|
+
tutuyoruz (iOS Photos benzeri). */}
|
|
411
|
+
<style>{`
|
|
412
|
+
.sentroy-cropper {
|
|
413
|
+
height: 100%;
|
|
414
|
+
width: 100%;
|
|
415
|
+
background: #000;
|
|
416
|
+
}
|
|
417
|
+
.sentroy-cropper-background {
|
|
418
|
+
background-color: rgba(0, 0, 0, 0.7);
|
|
419
|
+
}
|
|
420
|
+
.sentroy-cropper .advanced-cropper-stencil-overlay {
|
|
421
|
+
background: rgba(0, 0, 0, 0.55);
|
|
422
|
+
}
|
|
423
|
+
.sentroy-cropper .advanced-cropper-rectangle-stencil__draggable-area {
|
|
424
|
+
border: 1px solid rgba(255, 255, 255, 0.95);
|
|
425
|
+
}
|
|
426
|
+
.sentroy-cropper .advanced-cropper-line-wrapper {
|
|
427
|
+
background: rgba(255, 255, 255, 0.95);
|
|
428
|
+
}
|
|
429
|
+
.sentroy-cropper .advanced-cropper-handler-wrapper--west-north,
|
|
430
|
+
.sentroy-cropper .advanced-cropper-handler-wrapper--north-east,
|
|
431
|
+
.sentroy-cropper .advanced-cropper-handler-wrapper--east-south,
|
|
432
|
+
.sentroy-cropper .advanced-cropper-handler-wrapper--south-west {
|
|
433
|
+
width: 22px;
|
|
434
|
+
height: 22px;
|
|
435
|
+
}
|
|
436
|
+
.sentroy-cropper .advanced-cropper-handler-wrapper__draggable {
|
|
437
|
+
background: #fff;
|
|
438
|
+
border: 2px solid #000;
|
|
439
|
+
width: 12px;
|
|
440
|
+
height: 12px;
|
|
441
|
+
border-radius: 2px;
|
|
442
|
+
}
|
|
443
|
+
`}</style>
|
|
448
444
|
</motion.div>
|
|
449
445
|
)}
|
|
450
446
|
</AnimatePresence>
|
|
451
447
|
)
|
|
452
448
|
}
|
|
453
449
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
* orijinal image'i rotasyon merkezinden döndürdükten sonraki "visual"
|
|
461
|
-
* bounding box üzerinden hesaplanır — biz aynı transform'u canvas'a
|
|
462
|
-
* uygulayıp aynı koordinatlardan drawImage yapıyoruz.
|
|
463
|
-
*/
|
|
464
|
-
function drawRotatedCrop(
|
|
465
|
-
ctx: CanvasRenderingContext2D,
|
|
466
|
-
image: HTMLImageElement,
|
|
467
|
-
area: CropArea,
|
|
468
|
-
rotation: number,
|
|
469
|
-
dstW: number,
|
|
470
|
-
dstH: number,
|
|
471
|
-
): void {
|
|
472
|
-
if (rotation === 0) {
|
|
473
|
-
ctx.drawImage(
|
|
474
|
-
image,
|
|
475
|
-
area.x,
|
|
476
|
-
area.y,
|
|
477
|
-
area.width,
|
|
478
|
-
area.height,
|
|
479
|
-
0,
|
|
480
|
-
0,
|
|
481
|
-
dstW,
|
|
482
|
-
dstH,
|
|
483
|
-
)
|
|
484
|
-
return
|
|
485
|
-
}
|
|
486
|
-
// Build a rotated source canvas at the size of the rotated bounding box,
|
|
487
|
-
// then crop from it.
|
|
488
|
-
const rad = (rotation * Math.PI) / 180
|
|
489
|
-
const sin = Math.abs(Math.sin(rad))
|
|
490
|
-
const cos = Math.abs(Math.cos(rad))
|
|
491
|
-
const iw = image.naturalWidth
|
|
492
|
-
const ih = image.naturalHeight
|
|
493
|
-
const bbW = iw * cos + ih * sin
|
|
494
|
-
const bbH = iw * sin + ih * cos
|
|
495
|
-
const tmp = document.createElement("canvas")
|
|
496
|
-
tmp.width = bbW
|
|
497
|
-
tmp.height = bbH
|
|
498
|
-
const tctx = tmp.getContext("2d")
|
|
499
|
-
if (!tctx) return
|
|
500
|
-
tctx.translate(bbW / 2, bbH / 2)
|
|
501
|
-
tctx.rotate(rad)
|
|
502
|
-
tctx.drawImage(image, -iw / 2, -ih / 2)
|
|
503
|
-
ctx.drawImage(
|
|
504
|
-
tmp,
|
|
505
|
-
area.x,
|
|
506
|
-
area.y,
|
|
507
|
-
area.width,
|
|
508
|
-
area.height,
|
|
509
|
-
0,
|
|
510
|
-
0,
|
|
511
|
-
dstW,
|
|
512
|
-
dstH,
|
|
450
|
+
function Stat({ label, value }: { label: string; value: string }) {
|
|
451
|
+
return (
|
|
452
|
+
<div className="flex items-center justify-between">
|
|
453
|
+
<span>{label}</span>
|
|
454
|
+
<span className="font-mono text-white/80">{value}</span>
|
|
455
|
+
</div>
|
|
513
456
|
)
|
|
514
457
|
}
|
|
515
458
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
(blob) => (blob ? resolve(blob) : reject(new Error("toBlob returned null"))),
|
|
539
|
-
outputMime,
|
|
540
|
-
outputMime === "image/jpeg" ? quality : undefined,
|
|
541
|
-
)
|
|
542
|
-
})
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
546
|
-
return new Promise((resolve, reject) => {
|
|
547
|
-
const img = new Image()
|
|
548
|
-
img.onload = () => resolve(img)
|
|
549
|
-
img.onerror = reject
|
|
550
|
-
img.src = url
|
|
551
|
-
})
|
|
459
|
+
function ToolbarIconButton({
|
|
460
|
+
onClick,
|
|
461
|
+
title,
|
|
462
|
+
ariaLabel,
|
|
463
|
+
children,
|
|
464
|
+
}: {
|
|
465
|
+
onClick: () => void
|
|
466
|
+
title: string
|
|
467
|
+
ariaLabel: string
|
|
468
|
+
children: React.ReactNode
|
|
469
|
+
}) {
|
|
470
|
+
return (
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
onClick={onClick}
|
|
474
|
+
title={title}
|
|
475
|
+
aria-label={ariaLabel}
|
|
476
|
+
className="inline-flex size-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
|
477
|
+
>
|
|
478
|
+
{children}
|
|
479
|
+
</button>
|
|
480
|
+
)
|
|
552
481
|
}
|
|
553
482
|
|
|
554
483
|
function cls(...arr: Array<string | false | null | undefined>): string {
|
|
@@ -564,7 +493,7 @@ function RotateLeftIcon() {
|
|
|
564
493
|
strokeWidth="2"
|
|
565
494
|
strokeLinecap="round"
|
|
566
495
|
strokeLinejoin="round"
|
|
567
|
-
className="size-
|
|
496
|
+
className="size-4"
|
|
568
497
|
aria-hidden="true"
|
|
569
498
|
>
|
|
570
499
|
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
|
@@ -582,7 +511,7 @@ function RotateRightIcon() {
|
|
|
582
511
|
strokeWidth="2"
|
|
583
512
|
strokeLinecap="round"
|
|
584
513
|
strokeLinejoin="round"
|
|
585
|
-
className="size-
|
|
514
|
+
className="size-4"
|
|
586
515
|
aria-hidden="true"
|
|
587
516
|
>
|
|
588
517
|
<path d="M21 12a9 9 0 1 1-3-6.7" />
|
|
@@ -590,3 +519,41 @@ function RotateRightIcon() {
|
|
|
590
519
|
</svg>
|
|
591
520
|
)
|
|
592
521
|
}
|
|
522
|
+
|
|
523
|
+
function FlipHorizontalIcon() {
|
|
524
|
+
return (
|
|
525
|
+
<svg
|
|
526
|
+
viewBox="0 0 24 24"
|
|
527
|
+
fill="none"
|
|
528
|
+
stroke="currentColor"
|
|
529
|
+
strokeWidth="2"
|
|
530
|
+
strokeLinecap="round"
|
|
531
|
+
strokeLinejoin="round"
|
|
532
|
+
className="size-4"
|
|
533
|
+
aria-hidden="true"
|
|
534
|
+
>
|
|
535
|
+
<path d="M12 3v18" />
|
|
536
|
+
<path d="M16 7l4 5-4 5" />
|
|
537
|
+
<path d="M8 7l-4 5 4 5" />
|
|
538
|
+
</svg>
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function FlipVerticalIcon() {
|
|
543
|
+
return (
|
|
544
|
+
<svg
|
|
545
|
+
viewBox="0 0 24 24"
|
|
546
|
+
fill="none"
|
|
547
|
+
stroke="currentColor"
|
|
548
|
+
strokeWidth="2"
|
|
549
|
+
strokeLinecap="round"
|
|
550
|
+
strokeLinejoin="round"
|
|
551
|
+
className="size-4"
|
|
552
|
+
aria-hidden="true"
|
|
553
|
+
>
|
|
554
|
+
<path d="M3 12h18" />
|
|
555
|
+
<path d="M7 8l5-4 5 4" />
|
|
556
|
+
<path d="M7 16l5 4 5-4" />
|
|
557
|
+
</svg>
|
|
558
|
+
)
|
|
559
|
+
}
|