| 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 |