Files
Genarrative/src/components/common/SquareImageCropModal.tsx
kdletters ebd958b5a0 继续沉淀筛选工具条与裁剪弹窗壳层
新增共享 PlatformFilterToolbar 收口首页分类筛选与排序工具条
将 SquareImageCropModal 收口到 UnifiedModal 并补充薄能力透传
补充组件与调用侧回归测试并更新 PlatformUiKit 收口计划与共享决策记录
2026-06-11 05:16:46 +08:00

405 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type CSSProperties,
type PointerEvent,
useMemo,
useRef,
useState,
} from 'react';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformStatusMessage } from './PlatformStatusMessage';
import { UnifiedModal } from './UnifiedModal';
import {
clampNumber,
clampSquareImageCropRect,
getSquareCropSizeBounds,
type SquareImageCropRect,
} from './squareImageCropModel';
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<SquareCropDragHandle, 'move'>;
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 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<HTMLDivElement | null>(null);
const dragSnapshotRef = useRef<SquareCropDragSnapshot | null>(null);
const [activeDragHandle, setActiveDragHandle] =
useState<SquareCropDragHandle | null>(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<HTMLElement>,
) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
return;
}
dragSnapshotRef.current = null;
setActiveDragHandle(null);
event.currentTarget.releasePointerCapture(event.pointerId);
};
return (
<UnifiedModal
open
title={labels.title}
titleId={titleId}
onClose={onClose}
closeOnBackdrop={false}
closeOnEscape={false}
closeLabel={labels.close}
closeVariant="profileCompact"
closeIcon="×"
portal={false}
zIndexClassName="z-[80]"
panelClassName="platform-remap-surface max-w-sm rounded-[1.4rem]"
headerClassName="border-white/10 px-5 py-4"
titleClassName="font-black"
bodyClassName="px-5 py-5"
footerClassName="border-transparent px-5 pt-0 pb-5"
footer={
<div className="grid w-full grid-cols-2 gap-3">
<PlatformActionButton tone="secondary" onClick={onClose}>
{labels.cancel}
</PlatformActionButton>
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
{isSaving ? labels.saving : labels.submit}
</PlatformActionButton>
</div>
}
>
<div>
<div
ref={previewRef}
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
style={editorPreviewStyle}
aria-label={labels.editor}
>
<img
src={source}
alt={labels.previewAlt}
draggable={false}
className="h-full w-full object-fill"
/>
<div
className={`absolute border-2 border-[var(--platform-accent)] shadow-[0_0_0_9999px_rgba(61,31,16,0.34)] outline outline-1 outline-[rgba(74,34,15,0.35)] ${
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
}`}
style={previewStyle}
onPointerDown={(event) => beginCropDrag('move', event)}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
/>
<div
className="pointer-events-none absolute border border-white/70"
style={previewStyle}
>
<div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
<div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
<div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
<div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
</div>
<div className="pointer-events-none absolute" style={previewStyle}>
{SQUARE_CROP_RESIZE_HANDLES.map((handleConfig) => (
<button
key={handleConfig.handle}
type="button"
aria-label={handleConfig.label}
disabled={isSaving}
className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 ${handleConfig.className}`}
onPointerDown={(event) =>
beginCropDrag(handleConfig.handle, event)
}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
>
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-[var(--platform-accent)] shadow-[0_0_0_3px_rgba(204,117,76,0.32)]" />
</button>
))}
</div>
</div>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
className="mt-4 rounded-2xl"
>
{error}
</PlatformStatusMessage>
) : null}
</div>
</UnifiedModal>
);
}