新增共享 PlatformFilterToolbar 收口首页分类筛选与排序工具条 将 SquareImageCropModal 收口到 UnifiedModal 并补充薄能力透传 补充组件与调用侧回归测试并更新 PlatformUiKit 收口计划与共享决策记录
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|