import { type CSSProperties, type PointerEvent, useMemo, useRef, useState, } from 'react'; export type SquareImageCropRect = { x: number; y: number; size: number; }; export type SquareImageCropModalLabels = { title: string; close: string; editor: string; previewAlt: string; cancel: string; submit: string; saving: string; }; type SquareCropDragHandle = | 'move' | 'north' | 'northEast' | 'east' | 'southEast' | 'south' | 'southWest' | 'west' | 'northWest'; type SquareCropDragSnapshot = { pointerId: number; handle: SquareCropDragHandle; clientX: number; clientY: number; cropRect: SquareImageCropRect; previewWidth: number; previewHeight: number; }; const SQUARE_CROP_RESIZE_HANDLES: Array<{ handle: Exclude; label: string; className: string; }> = [ { handle: 'northWest', label: '拖拽左上角裁剪边界', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize', }, { handle: 'north', label: '拖拽上边裁剪边界', className: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize', }, { handle: 'northEast', label: '拖拽右上角裁剪边界', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize', }, { handle: 'east', label: '拖拽右边裁剪边界', className: 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize', }, { handle: 'southEast', label: '拖拽右下角裁剪边界', className: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize', }, { handle: 'south', label: '拖拽下边裁剪边界', className: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize', }, { handle: 'southWest', label: '拖拽左下角裁剪边界', className: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize', }, { handle: 'west', label: '拖拽左边裁剪边界', className: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize', }, ]; function clampNumber(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function getSquareCropSizeBounds(imageSize: { width: number; height: number }) { const maxSize = Math.max(1, Math.min(imageSize.width, imageSize.height)); const minSize = Math.min(maxSize, Math.max(48, maxSize * 0.18)); return { minSize, maxSize }; } export function buildCenteredSquareImageCropRect(imageSize: { width: number; height: number; }): SquareImageCropRect { const size = Math.max(1, Math.min(imageSize.width, imageSize.height)); return { x: Math.max(0, (imageSize.width - size) / 2), y: Math.max(0, (imageSize.height - size) / 2), size, }; } export function clampSquareImageCropRect( imageSize: { width: number; height: number }, crop: SquareImageCropRect, ): SquareImageCropRect { const { minSize, maxSize } = getSquareCropSizeBounds(imageSize); const size = clampNumber(crop.size, minSize, maxSize); return { x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)), y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)), size, }; } function buildSquareCropPreviewStyle( crop: SquareImageCropRect, imageSize: { width: number; height: number }, ) { return { left: `${(crop.x / imageSize.width) * 100}%`, top: `${(crop.y / imageSize.height) * 100}%`, width: `${(crop.size / imageSize.width) * 100}%`, height: `${(crop.size / imageSize.height) * 100}%`, } satisfies CSSProperties; } function resizeSquareCropRectFromHandle( snapshot: SquareCropDragSnapshot, deltaX: number, deltaY: number, imageSize: { width: number; height: number }, ) { const start = snapshot.cropRect; const startRight = start.x + start.size; const startBottom = start.y + start.size; const startCenterX = start.x + start.size / 2; const startCenterY = start.y + start.size / 2; const { minSize, maxSize } = getSquareCropSizeBounds(imageSize); const chooseSize = (sizeFromX: number, sizeFromY: number) => { const xDistance = Math.abs(sizeFromX - start.size); const yDistance = Math.abs(sizeFromY - start.size); return xDistance >= yDistance ? sizeFromX : sizeFromY; }; const clampSize = (size: number, maxByAnchor = maxSize) => clampNumber( size, minSize, Math.max(minSize, Math.min(maxSize, maxByAnchor)), ); if (snapshot.handle === 'move') { return clampSquareImageCropRect(imageSize, { ...start, x: start.x + deltaX, y: start.y + deltaY, }); } // 中文注释:边缘手柄保持正方形比例,锚点逻辑与拼图图片上传裁剪一致。 if (snapshot.handle === 'east' || snapshot.handle === 'west') { const isEast = snapshot.handle === 'east'; const anchorX = isEast ? start.x : startRight; const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX; const maxByCenterY = 2 * Math.min(startCenterY, imageSize.height - startCenterY); const size = clampSize( start.size + (isEast ? deltaX : -deltaX), Math.min(maxByAnchorX, maxByCenterY), ); return clampSquareImageCropRect(imageSize, { x: isEast ? anchorX : anchorX - size, y: startCenterY - size / 2, size, }); } if (snapshot.handle === 'north' || snapshot.handle === 'south') { const isSouth = snapshot.handle === 'south'; const anchorY = isSouth ? start.y : startBottom; const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY; const maxByCenterX = 2 * Math.min(startCenterX, imageSize.width - startCenterX); const size = clampSize( start.size + (isSouth ? deltaY : -deltaY), Math.min(maxByAnchorY, maxByCenterX), ); return clampSquareImageCropRect(imageSize, { x: startCenterX - size / 2, y: isSouth ? anchorY : anchorY - size, size, }); } const isEast = snapshot.handle === 'northEast' || snapshot.handle === 'southEast'; const isSouth = snapshot.handle === 'southEast' || snapshot.handle === 'southWest'; const anchorX = isEast ? start.x : startRight; const anchorY = isSouth ? start.y : startBottom; const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX; const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY; const sizeFromX = start.size + (isEast ? deltaX : -deltaX); const sizeFromY = start.size + (isSouth ? deltaY : -deltaY); const size = clampSize( chooseSize(sizeFromX, sizeFromY), Math.min(maxByAnchorX, maxByAnchorY), ); return clampSquareImageCropRect(imageSize, { x: isEast ? anchorX : anchorX - size, y: isSouth ? anchorY : anchorY - size, size, }); } export function SquareImageCropModal({ source, imageSize, cropRect, labels, titleId = 'square-image-crop-title', error = null, isSaving = false, onCropRectChange, onClose, onSubmit, }: { source: string; imageSize: { width: number; height: number }; cropRect: SquareImageCropRect; labels: SquareImageCropModalLabels; titleId?: string; error?: string | null; isSaving?: boolean; onCropRectChange: (nextCrop: SquareImageCropRect) => void; onClose: () => void; onSubmit: () => void; }) { const previewRef = useRef(null); const dragSnapshotRef = useRef(null); const [activeDragHandle, setActiveDragHandle] = useState(null); const normalizedCropRect = useMemo( () => clampSquareImageCropRect(imageSize, cropRect), [cropRect, imageSize], ); const previewStyle = useMemo( () => buildSquareCropPreviewStyle(normalizedCropRect, imageSize), [normalizedCropRect, imageSize], ); const editorPreviewStyle = useMemo( () => ({ aspectRatio: `${imageSize.width} / ${imageSize.height}`, width: `min(100%, calc(min(52vh, 22rem) * ${ imageSize.width / Math.max(1, imageSize.height) }))`, }) satisfies CSSProperties, [imageSize], ); const beginCropDrag = ( handle: SquareCropDragHandle, event: PointerEvent, ) => { if (isSaving) { return; } const preview = previewRef.current; if (!preview) { return; } const rect = preview.getBoundingClientRect(); dragSnapshotRef.current = { pointerId: event.pointerId, handle, clientX: event.clientX, clientY: event.clientY, cropRect: normalizedCropRect, previewWidth: rect.width, previewHeight: rect.height, }; setActiveDragHandle(handle); event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); }; const updateCropDrag = (event: PointerEvent) => { const snapshot = dragSnapshotRef.current; if (!snapshot || snapshot.pointerId !== event.pointerId) { return; } const deltaX = ((event.clientX - snapshot.clientX) * imageSize.width) / Math.max(1, snapshot.previewWidth); const deltaY = ((event.clientY - snapshot.clientY) * imageSize.height) / Math.max(1, snapshot.previewHeight); onCropRectChange( resizeSquareCropRectFromHandle(snapshot, deltaX, deltaY, imageSize), ); }; const stopCropDrag = (event: PointerEvent) => { if (dragSnapshotRef.current?.pointerId !== event.pointerId) { return; } dragSnapshotRef.current = null; setActiveDragHandle(null); event.currentTarget.releasePointerCapture(event.pointerId); }; return (
{labels.title}
{labels.previewAlt}
beginCropDrag('move', event)} onPointerMove={updateCropDrag} onPointerUp={stopCropDrag} onPointerCancel={stopCropDrag} />
{SQUARE_CROP_RESIZE_HANDLES.map((handleConfig) => ( ))}
{error ? (
{error}
) : null}
); }