Files
Genarrative/src/components/common/SquareImageCropModal.tsx
T
kdletters 387a1c26e3 完成全量检查整改
清理已跟踪原始日志与个人本地配置
修复前后端有效门禁、测试和构建问题
补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束
收紧图片编辑器状态、附件与媒体引用测试
排除已下线旧创作入口测试并清理 warning
2026-07-17 16:56: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 {
clampNumber,
clampSquareImageCropRect,
getSquareCropSizeBounds,
type SquareImageCropRect,
} from './squareImageCropModel';
import { UnifiedModal } from './UnifiedModal';
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>
);
}