返回 AiToEarn
CropOverlay.tsx
1 /**
2 * CropOverlay - 裁剪框覆盖层组件
3 * 使用 DOM 元素实现裁剪框交互(暗化遮罩 + 虚线边框 + 8 个手柄)
4 */
5
6 'use client'
7
8 import type { CropRect } from './useCropEditor'
9 import { memo, useCallback, useRef } from 'react'
10
11 /** 手柄位置类型 */
12 type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
13
14 /** 手柄配置 - 使用 -6px 偏移让手柄在移动端更容易触摸 */
15 const HANDLES: { pos: HandlePosition, cursor: string, style: Record<string, string> }[] = [
16 { pos: 'nw', cursor: 'nwse-resize', style: { top: '-6px', left: '-6px' } },
17 { pos: 'n', cursor: 'ns-resize', style: { top: '-6px', left: '50%', transform: 'translateX(-50%)' } },
18 { pos: 'ne', cursor: 'nesw-resize', style: { top: '-6px', right: '-6px' } },
19 { pos: 'e', cursor: 'ew-resize', style: { top: '50%', right: '-6px', transform: 'translateY(-50%)' } },
20 { pos: 'se', cursor: 'nwse-resize', style: { bottom: '-6px', right: '-6px' } },
21 { pos: 's', cursor: 'ns-resize', style: { bottom: '-6px', left: '50%', transform: 'translateX(-50%)' } },
22 { pos: 'sw', cursor: 'nesw-resize', style: { bottom: '-6px', left: '-6px' } },
23 { pos: 'w', cursor: 'ew-resize', style: { top: '50%', left: '-6px', transform: 'translateY(-50%)' } },
24 ]
25
26 export interface CropOverlayProps {
27 /** 裁剪框(Canvas 像素坐标) */
28 cropRect: CropRect
29 /** 更新裁剪框 */
30 onCropRectChange: (rect: CropRect) => void
31 /** Canvas 像素尺寸 */
32 canvasSize: { width: number, height: number }
33 /** 显示尺寸 */
34 displaySize: { width: number, height: number }
35 /** 宽高比锁定(0 = 自由) */
36 aspectRatio: number
37 }
38
39 export const CropOverlay = memo(({
40 cropRect,
41 onCropRectChange,
42 canvasSize,
43 displaySize,
44 aspectRatio,
45 }: CropOverlayProps) => {
46 const dragRef = useRef<{
47 type: 'move' | HandlePosition
48 startX: number
49 startY: number
50 startRect: CropRect
51 } | null>(null)
52
53 // 坐标转换比例
54 const scaleX = canvasSize.width / displaySize.width
55 const scaleY = canvasSize.height / displaySize.height
56
57 // Canvas 坐标 -> 显示坐标
58 const toDisplay = useCallback((rect: CropRect) => ({
59 x: rect.x / scaleX,
60 y: rect.y / scaleY,
61 width: rect.width / scaleX,
62 height: rect.height / scaleY,
63 }), [scaleX, scaleY])
64
65 const displayRect = toDisplay(cropRect)
66
67 /** 约束裁剪框在 Canvas 范围内 */
68 const clampRect = useCallback((rect: CropRect): CropRect => {
69 const minSize = 20
70 let { x, y, width, height } = rect
71 width = Math.max(minSize, Math.min(width, canvasSize.width))
72 height = Math.max(minSize, Math.min(height, canvasSize.height))
73 x = Math.max(0, Math.min(x, canvasSize.width - width))
74 y = Math.max(0, Math.min(y, canvasSize.height - height))
75 return { x, y, width, height }
76 }, [canvasSize])
77
78 /** 处理拖拽移动 */
79 const handlePointerDown = useCallback((e: React.PointerEvent, type: 'move' | HandlePosition) => {
80 e.preventDefault()
81 e.stopPropagation()
82 ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
83
84 dragRef.current = {
85 type,
86 startX: e.clientX,
87 startY: e.clientY,
88 startRect: { ...cropRect },
89 }
90 }, [cropRect])
91
92 const handlePointerMove = useCallback((e: React.PointerEvent) => {
93 if (!dragRef.current)
94 return
95
96 const { type, startX, startY, startRect } = dragRef.current
97 const dx = (e.clientX - startX) * scaleX
98 const dy = (e.clientY - startY) * scaleY
99
100 if (type === 'move') {
101 onCropRectChange(clampRect({
102 ...startRect,
103 x: startRect.x + dx,
104 y: startRect.y + dy,
105 }))
106 return
107 }
108
109 // 手柄缩放
110 let { x, y, width, height } = startRect
111
112 // 根据手柄位置计算新尺寸
113 if (type.includes('e')) {
114 width = startRect.width + dx
115 }
116 if (type.includes('w')) {
117 width = startRect.width - dx
118 x = startRect.x + dx
119 }
120 if (type.includes('s')) {
121 height = startRect.height + dy
122 }
123 if (type.includes('n')) {
124 height = startRect.height - dy
125 y = startRect.y + dy
126 }
127
128 // 最小尺寸
129 const minSize = 20
130 if (width < minSize) {
131 if (type.includes('w'))
132 x = startRect.x + startRect.width - minSize
133 width = minSize
134 }
135 if (height < minSize) {
136 if (type.includes('n'))
137 y = startRect.y + startRect.height - minSize
138 height = minSize
139 }
140
141 // 比例锁定
142 if (aspectRatio > 0) {
143 if (type === 'n' || type === 's') {
144 width = height * aspectRatio
145 x = startRect.x + (startRect.width - width) / 2
146 }
147 else if (type === 'e' || type === 'w') {
148 height = width / aspectRatio
149 y = startRect.y + (startRect.height - height) / 2
150 }
151 else {
152 // 角手柄:以宽度为准
153 const newHeight = width / aspectRatio
154 if (type.includes('n')) {
155 y = startRect.y + startRect.height - newHeight
156 }
157 height = newHeight
158 }
159 }
160
161 onCropRectChange(clampRect({ x, y, width, height }))
162 }, [scaleX, scaleY, aspectRatio, onCropRectChange, clampRect])
163
164 const handlePointerUp = useCallback(() => {
165 dragRef.current = null
166 }, [])
167
168 return (
169 <div
170 className="absolute inset-0"
171 style={{ touchAction: 'none' }}
172 onPointerMove={handlePointerMove}
173 onPointerUp={handlePointerUp}
174 >
175 {/* 暗化遮罩 - 上 */}
176 <div
177 className="absolute left-0 right-0 top-0 bg-black/50"
178 style={{ height: displayRect.y }}
179 />
180 {/* 暗化遮罩 - 下 */}
181 <div
182 className="absolute left-0 right-0 bottom-0 bg-black/50"
183 style={{ height: displaySize.height - displayRect.y - displayRect.height }}
184 />
185 {/* 暗化遮罩 - 左 */}
186 <div
187 className="absolute left-0 bg-black/50"
188 style={{
189 top: displayRect.y,
190 width: displayRect.x,
191 height: displayRect.height,
192 }}
193 />
194 {/* 暗化遮罩 - 右 */}
195 <div
196 className="absolute right-0 bg-black/50"
197 style={{
198 top: displayRect.y,
199 width: displaySize.width - displayRect.x - displayRect.width,
200 height: displayRect.height,
201 }}
202 />
203
204 {/* 裁剪框 */}
205 <div
206 className="absolute border-2 border-white cursor-move"
207 style={{
208 left: displayRect.x,
209 top: displayRect.y,
210 width: displayRect.width,
211 height: displayRect.height,
212 }}
213 onPointerDown={e => handlePointerDown(e, 'move')}
214 >
215 {/* 三分线 */}
216 <div className="absolute inset-0 pointer-events-none">
217 <div className="absolute left-1/3 top-0 bottom-0 w-px bg-white/30" />
218 <div className="absolute left-2/3 top-0 bottom-0 w-px bg-white/30" />
219 <div className="absolute top-1/3 left-0 right-0 h-px bg-white/30" />
220 <div className="absolute top-2/3 left-0 right-0 h-px bg-white/30" />
221 </div>
222
223 {/* 8 个手柄 */}
224 {HANDLES.map(({ pos, cursor, style }) => (
225 <div
226 key={pos}
227 className="absolute w-3.5 h-3.5 sm:w-3 sm:h-3 bg-white rounded-sm shadow-md"
228 style={{ ...style, cursor }}
229 onPointerDown={e => handlePointerDown(e, pos)}
230 />
231 ))}
232 </div>
233 </div>
234 )
235 })
236
237 export default CropOverlay
238
238 lines Plain Text