@sentroy-co/client-sdk 2.13.1 → 2.13.3
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,40 @@
|
|
|
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
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Image crop dialog —
|
|
6
|
+
* Image crop dialog — iOS Photos benzeri full-screen crop UI. Önceki
|
|
7
|
+
* `react-easy-crop` implementation'ı drag UX ve preview render
|
|
8
|
+
* tarafında zayıftı; `react-advanced-cropper` daha modern stencil
|
|
9
|
+
* sistemi + native-feel pinch/zoom verir.
|
|
10
|
+
*
|
|
11
|
+
* **CSS:** Caller uygulamada `react-advanced-cropper/dist/style.css`'i
|
|
12
|
+
* global olarak import edilmiş olmalıdır (örn. `globals.css` üzerinden veya
|
|
13
|
+
* root layout). SDK içinden CSS import etsek `tsc`'nin CJS çıktısında
|
|
14
|
+
* runtime `require("...css")` doğar, Next.js bunu bundle'a alamaz ve
|
|
15
|
+
* "Module factory is not available" hatasıyla patlar.
|
|
16
|
+
*
|
|
17
|
+
* Akış (storage upload pipeline'ından preprocess hook):
|
|
7
18
|
* - 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.
|
|
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.
|
|
17
25
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
26
|
+
* Tam ekran: `inset-0`, scrim'siz; consent flow gibi destination-only UX —
|
|
27
|
+
* dialog her şeyi kaplar, dikkat dağıtmaz.
|
|
20
28
|
*
|
|
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.
|
|
29
|
+
* Lazy subpath (`@sentroy-co/client-sdk/react/crop`) — ana SDK import'u
|
|
30
|
+
* cropper bundle'ı yutmasın.
|
|
26
31
|
*/
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }> = [
|
|
33
|
+
const ASPECT_PRESETS: Array<{
|
|
34
|
+
id: string
|
|
35
|
+
label: string
|
|
36
|
+
aspect: number | null
|
|
37
|
+
}> = [
|
|
36
38
|
{ id: "free", label: "Free", aspect: null },
|
|
37
39
|
{ id: "1:1", label: "1:1", aspect: 1 },
|
|
38
40
|
{ id: "16:9", label: "16:9", aspect: 16 / 9 },
|
|
@@ -42,7 +44,7 @@ const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }
|
|
|
42
44
|
]
|
|
43
45
|
|
|
44
46
|
const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP — üstü tarayıcı memory peak'i riskli
|
|
45
|
-
const PREVIEW_MAX_DIM =
|
|
47
|
+
const PREVIEW_MAX_DIM = 240
|
|
46
48
|
|
|
47
49
|
export interface CropDialogProps {
|
|
48
50
|
open: boolean
|
|
@@ -67,99 +69,124 @@ export function CropDialog({
|
|
|
67
69
|
}: CropDialogProps) {
|
|
68
70
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
|
69
71
|
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
72
|
const [busy, setBusy] = useState(false)
|
|
75
73
|
const [tooLarge, setTooLarge] = useState(false)
|
|
76
|
-
const
|
|
74
|
+
const [outputSize, setOutputSize] = useState<{ w: number; h: number } | null>(
|
|
75
|
+
null,
|
|
76
|
+
)
|
|
77
|
+
const [previewDataUrl, setPreviewDataUrl] = useState<string | null>(null)
|
|
78
|
+
|
|
79
|
+
const cropperRef = useRef<CropperRef | null>(null)
|
|
77
80
|
const previewRafRef = useRef<number | null>(null)
|
|
78
|
-
const imageElRef = useRef<HTMLImageElement | null>(null)
|
|
79
81
|
|
|
80
82
|
// Object URL lifecycle
|
|
81
83
|
useEffect(() => {
|
|
82
84
|
if (!open) return
|
|
83
85
|
const url = URL.createObjectURL(file)
|
|
84
86
|
setImageUrl(url)
|
|
85
|
-
setCrop({ x: 0, y: 0 })
|
|
86
|
-
setZoom(1)
|
|
87
|
-
setRotation(0)
|
|
88
87
|
setAspectId(defaultAspect)
|
|
89
88
|
setTooLarge(false)
|
|
90
|
-
|
|
89
|
+
setOutputSize(null)
|
|
90
|
+
setPreviewDataUrl(null)
|
|
91
|
+
// Pixel guard — large image decode tarayıcıyı çökertir
|
|
91
92
|
const img = new Image()
|
|
92
93
|
img.onload = () => {
|
|
93
94
|
if (img.naturalWidth * img.naturalHeight > MAX_PIXEL_GUARD) {
|
|
94
95
|
setTooLarge(true)
|
|
95
96
|
}
|
|
96
|
-
imageElRef.current = img
|
|
97
97
|
}
|
|
98
98
|
img.src = url
|
|
99
99
|
return () => {
|
|
100
100
|
URL.revokeObjectURL(url)
|
|
101
|
-
|
|
101
|
+
if (previewRafRef.current !== null) {
|
|
102
|
+
cancelAnimationFrame(previewRafRef.current)
|
|
103
|
+
previewRafRef.current = null
|
|
104
|
+
}
|
|
102
105
|
}
|
|
103
106
|
}, [open, file, defaultAspect])
|
|
104
107
|
|
|
105
|
-
const
|
|
106
|
-
(
|
|
107
|
-
setCroppedAreaPixels(areaPixels)
|
|
108
|
-
},
|
|
109
|
-
[],
|
|
110
|
-
)
|
|
108
|
+
const aspectRatio =
|
|
109
|
+
ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
|
|
111
110
|
|
|
112
|
-
//
|
|
113
|
-
// güncelle.
|
|
114
|
-
// canvas çizmek pahalı).
|
|
111
|
+
// Aspect preset değişirse cropper coordinates'ini yeni aspect'e göre
|
|
112
|
+
// güncelle. `setCoordinates` ile aspect'i zorla.
|
|
115
113
|
useEffect(() => {
|
|
116
|
-
if (!
|
|
114
|
+
if (!cropperRef.current) return
|
|
115
|
+
if (aspectRatio === undefined) return
|
|
116
|
+
const state = cropperRef.current.getState()
|
|
117
|
+
if (!state) return
|
|
118
|
+
const { coordinates } = state
|
|
119
|
+
if (!coordinates) return
|
|
120
|
+
const current = coordinates.width / coordinates.height
|
|
121
|
+
if (Math.abs(current - aspectRatio) < 0.001) return
|
|
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
|
+
})
|
|
129
|
+
}, [aspectRatio, aspectId])
|
|
130
|
+
|
|
131
|
+
// Live preview render — cropper change event'inde getCanvas() ile
|
|
132
|
+
// küçük thumbnail üret. RAF ile throttle.
|
|
133
|
+
const renderPreview = useCallback(() => {
|
|
117
134
|
if (previewRafRef.current !== null) {
|
|
118
135
|
cancelAnimationFrame(previewRafRef.current)
|
|
119
136
|
}
|
|
120
137
|
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
138
|
previewRafRef.current = null
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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ç
|
|
145
154
|
}
|
|
146
|
-
}
|
|
147
|
-
}, [
|
|
155
|
+
})
|
|
156
|
+
}, [])
|
|
148
157
|
|
|
149
|
-
const
|
|
150
|
-
|
|
158
|
+
const handleCropperChange = useCallback(() => {
|
|
159
|
+
renderPreview()
|
|
160
|
+
}, [renderPreview])
|
|
161
|
+
|
|
162
|
+
const handleCropperReady = useCallback(() => {
|
|
163
|
+
renderPreview()
|
|
164
|
+
}, [renderPreview])
|
|
151
165
|
|
|
152
166
|
const handleApply = useCallback(async () => {
|
|
153
|
-
|
|
167
|
+
const cropper = cropperRef.current
|
|
168
|
+
if (!cropper) return
|
|
154
169
|
setBusy(true)
|
|
155
170
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
171
|
+
const canvas = cropper.getCanvas({
|
|
172
|
+
imageSmoothingQuality: "high",
|
|
173
|
+
})
|
|
174
|
+
if (!canvas) {
|
|
175
|
+
setBusy(false)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
const outputMime = file.type === "image/png" ? "image/png" : "image/jpeg"
|
|
179
|
+
const blob = await new Promise<Blob | null>((resolve) => {
|
|
180
|
+
canvas.toBlob(
|
|
181
|
+
(b) => resolve(b),
|
|
182
|
+
outputMime,
|
|
183
|
+
outputMime === "image/jpeg" ? outputQuality : undefined,
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
if (!blob) {
|
|
187
|
+
setBusy(false)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
163
190
|
const ext = blob.type === "image/png" ? "png" : "jpg"
|
|
164
191
|
const baseName = file.name.replace(/\.[^.]+$/, "")
|
|
165
192
|
const cropped = new File([blob], `${baseName}.${ext}`, {
|
|
@@ -169,34 +196,33 @@ export function CropDialog({
|
|
|
169
196
|
} finally {
|
|
170
197
|
setBusy(false)
|
|
171
198
|
}
|
|
172
|
-
}, [
|
|
199
|
+
}, [file, onClose, outputQuality])
|
|
173
200
|
|
|
174
201
|
const handleUseOriginal = useCallback(() => onClose(file), [file, onClose])
|
|
175
202
|
const handleCancel = useCallback(() => onClose(null), [onClose])
|
|
176
|
-
const handleRotate = useCallback(
|
|
177
|
-
(delta
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
},
|
|
183
|
-
[],
|
|
184
|
-
)
|
|
203
|
+
const handleRotate = useCallback((delta: 90 | -90) => {
|
|
204
|
+
cropperRef.current?.rotateImage(delta)
|
|
205
|
+
}, [])
|
|
206
|
+
const handleFlip = useCallback((axis: "h" | "v") => {
|
|
207
|
+
cropperRef.current?.flipImage(axis === "h", axis === "v")
|
|
208
|
+
}, [])
|
|
185
209
|
|
|
186
|
-
// ESC
|
|
210
|
+
// Keyboard shortcuts — ESC kapat, R rotate, F flip
|
|
187
211
|
useEffect(() => {
|
|
188
212
|
if (!open) return
|
|
189
213
|
const onKey = (e: KeyboardEvent) => {
|
|
214
|
+
if (
|
|
215
|
+
e.target instanceof HTMLElement &&
|
|
216
|
+
(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
|
217
|
+
) {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
190
220
|
if (e.key === "Escape") {
|
|
191
221
|
e.stopPropagation()
|
|
192
222
|
handleCancel()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
|
197
|
-
) {
|
|
198
|
-
return
|
|
199
|
-
}
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (e.key === "r" || e.key === "R") {
|
|
200
226
|
e.preventDefault()
|
|
201
227
|
handleRotate(e.shiftKey ? -90 : 90)
|
|
202
228
|
}
|
|
@@ -205,13 +231,6 @@ export function CropDialog({
|
|
|
205
231
|
return () => window.removeEventListener("keydown", onKey)
|
|
206
232
|
}, [open, handleCancel, handleRotate])
|
|
207
233
|
|
|
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
234
|
return (
|
|
216
235
|
<AnimatePresence>
|
|
217
236
|
{open && imageUrl && (
|
|
@@ -221,334 +240,249 @@ export function CropDialog({
|
|
|
221
240
|
animate={{ opacity: 1 }}
|
|
222
241
|
exit={{ opacity: 0 }}
|
|
223
242
|
transition={{ duration: 0.2 }}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (e.target === e.currentTarget) handleCancel()
|
|
227
|
-
}}
|
|
243
|
+
// Tam ekran — scrim değil, kendisi background; iOS Photos UX
|
|
244
|
+
className="fixed inset-0 z-[60] flex flex-col bg-black text-white"
|
|
228
245
|
>
|
|
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>
|
|
246
|
+
{/* Header */}
|
|
247
|
+
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-black/40 px-4 py-3 backdrop-blur-sm">
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={handleCancel}
|
|
251
|
+
disabled={busy}
|
|
252
|
+
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"
|
|
253
|
+
>
|
|
254
|
+
Cancel
|
|
255
|
+
</button>
|
|
256
|
+
<div className="flex min-w-0 flex-col items-center text-center">
|
|
257
|
+
<span className="text-sm font-semibold">Crop image</span>
|
|
258
|
+
<span className="truncate max-w-xs text-[11px] text-white/50">
|
|
259
|
+
{file.name}
|
|
260
|
+
</span>
|
|
262
261
|
</div>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={handleApply}
|
|
265
|
+
disabled={busy || tooLarge}
|
|
266
|
+
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
267
|
+
>
|
|
268
|
+
{busy ? "Cropping…" : "Apply"}
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
263
271
|
|
|
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">
|
|
272
|
+
{/* Aspect + rotate toolbar */}
|
|
273
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 bg-black/30 px-3 py-2">
|
|
274
|
+
<div className="flex flex-1 flex-wrap items-center gap-1">
|
|
275
|
+
{ASPECT_PRESETS.map((p) => (
|
|
284
276
|
<button
|
|
277
|
+
key={p.id}
|
|
285
278
|
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"
|
|
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
|
+
)}
|
|
299
286
|
>
|
|
300
|
-
|
|
287
|
+
{p.label}
|
|
301
288
|
</button>
|
|
302
|
-
|
|
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"
|
|
303
|
+
>
|
|
304
|
+
<RotateRightIcon />
|
|
305
|
+
</ToolbarIconButton>
|
|
306
|
+
<span className="mx-1 h-5 w-px bg-white/15" />
|
|
307
|
+
<ToolbarIconButton
|
|
308
|
+
onClick={() => handleFlip("h")}
|
|
309
|
+
title="Flip horizontal"
|
|
310
|
+
ariaLabel="Flip horizontal"
|
|
311
|
+
>
|
|
312
|
+
<FlipHorizontalIcon />
|
|
313
|
+
</ToolbarIconButton>
|
|
314
|
+
<ToolbarIconButton
|
|
315
|
+
onClick={() => handleFlip("v")}
|
|
316
|
+
title="Flip vertical"
|
|
317
|
+
ariaLabel="Flip vertical"
|
|
318
|
+
>
|
|
319
|
+
<FlipVerticalIcon />
|
|
320
|
+
</ToolbarIconButton>
|
|
303
321
|
</div>
|
|
322
|
+
</div>
|
|
304
323
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
{/* Main: cropper + side panel */}
|
|
325
|
+
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
|
326
|
+
{/* Cropper stage */}
|
|
327
|
+
<div className="relative flex-1 bg-black min-h-[240px]">
|
|
328
|
+
{tooLarge ? (
|
|
329
|
+
<div className="flex h-full w-full items-center justify-center p-6 text-center text-sm text-white/70">
|
|
330
|
+
Image too large to crop in browser. Upload as-is or resize
|
|
331
|
+
beforehand.
|
|
332
|
+
</div>
|
|
333
|
+
) : (
|
|
334
|
+
<Cropper
|
|
335
|
+
ref={cropperRef}
|
|
336
|
+
src={imageUrl}
|
|
337
|
+
className="sentroy-cropper"
|
|
338
|
+
// Stencil props — aspect lock + iOS-like rect stencil grid
|
|
339
|
+
stencilProps={{
|
|
340
|
+
aspectRatio: aspectRatio,
|
|
341
|
+
grid: true,
|
|
342
|
+
movable: true,
|
|
343
|
+
resizable: true,
|
|
344
|
+
}}
|
|
345
|
+
// Background overlay'i koyu yap (image dışı kalan kısım)
|
|
346
|
+
backgroundClassName="sentroy-cropper-background"
|
|
347
|
+
onChange={handleCropperChange}
|
|
348
|
+
onReady={handleCropperReady}
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{/* Side panel: live preview + readout */}
|
|
354
|
+
<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">
|
|
355
|
+
<div className="flex items-center justify-between">
|
|
356
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-white/50">
|
|
357
|
+
Preview
|
|
358
|
+
</span>
|
|
359
|
+
{outputSize && (
|
|
360
|
+
<span className="font-mono text-[10px] text-white/40">
|
|
361
|
+
{outputSize.w}×{outputSize.h}
|
|
362
|
+
</span>
|
|
328
363
|
)}
|
|
329
364
|
</div>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
335
|
-
Preview
|
|
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
|
|
336
369
|
</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>
|
|
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
|
+
)}
|
|
376
382
|
</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"
|
|
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`}
|
|
399
395
|
/>
|
|
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>
|
|
396
|
+
)}
|
|
397
|
+
<Stat
|
|
398
|
+
label="Format"
|
|
399
|
+
value={file.type === "image/png" ? "PNG" : "JPEG"}
|
|
400
|
+
/>
|
|
445
401
|
</div>
|
|
446
|
-
|
|
447
|
-
|
|
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). */}
|
|
416
|
+
<style>{`
|
|
417
|
+
.sentroy-cropper {
|
|
418
|
+
height: 100%;
|
|
419
|
+
width: 100%;
|
|
420
|
+
background: #000;
|
|
421
|
+
}
|
|
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
|
+
`}</style>
|
|
448
449
|
</motion.div>
|
|
449
450
|
)}
|
|
450
451
|
</AnimatePresence>
|
|
451
452
|
)
|
|
452
453
|
}
|
|
453
454
|
|
|
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,
|
|
455
|
+
function Stat({ label, value }: { label: string; value: string }) {
|
|
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>
|
|
513
461
|
)
|
|
514
462
|
}
|
|
515
463
|
|
|
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
|
-
})
|
|
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
|
+
)
|
|
552
486
|
}
|
|
553
487
|
|
|
554
488
|
function cls(...arr: Array<string | false | null | undefined>): string {
|
|
@@ -564,7 +498,7 @@ function RotateLeftIcon() {
|
|
|
564
498
|
strokeWidth="2"
|
|
565
499
|
strokeLinecap="round"
|
|
566
500
|
strokeLinejoin="round"
|
|
567
|
-
className="size-
|
|
501
|
+
className="size-4"
|
|
568
502
|
aria-hidden="true"
|
|
569
503
|
>
|
|
570
504
|
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
|
@@ -582,7 +516,7 @@ function RotateRightIcon() {
|
|
|
582
516
|
strokeWidth="2"
|
|
583
517
|
strokeLinecap="round"
|
|
584
518
|
strokeLinejoin="round"
|
|
585
|
-
className="size-
|
|
519
|
+
className="size-4"
|
|
586
520
|
aria-hidden="true"
|
|
587
521
|
>
|
|
588
522
|
<path d="M21 12a9 9 0 1 1-3-6.7" />
|
|
@@ -590,3 +524,41 @@ function RotateRightIcon() {
|
|
|
590
524
|
</svg>
|
|
591
525
|
)
|
|
592
526
|
}
|
|
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
|
+
}
|