This commit is contained in:
2026-05-08 20:48:29 +08:00
parent abf1f1ebea
commit 94975e4735
82 changed files with 7786 additions and 1012 deletions

View File

@@ -1,6 +1,5 @@
import { X } from 'lucide-react';
import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react';
import type { ChangeEvent, CSSProperties, PointerEvent } from 'react';
import {
Children,
type ReactNode,
@@ -954,18 +953,123 @@ function loadImageDimensionsFromDataUrl(source: string) {
});
}
const COVER_CROP_RATIO = 16 / 9;
type CoverCropDragHandle =
| 'move'
| 'north'
| 'northEast'
| 'east'
| 'southEast'
| 'south'
| 'southWest'
| 'west'
| 'northWest';
type CoverCropDragSnapshot = {
pointerId: number;
handle: CoverCropDragHandle;
clientX: number;
clientY: number;
cropRect: CustomWorldCoverCropRect;
previewWidth: number;
previewHeight: number;
};
const COVER_CROP_RESIZE_HANDLES: Array<{
handle: Exclude<CoverCropDragHandle, 'move'>;
label: string;
className: string;
dotClassName: string;
}> = [
{
handle: 'northWest',
label: '拖拽左上角裁剪边界',
className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'north',
label: '拖拽上边裁剪边界',
className: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'northEast',
label: '拖拽右上角裁剪边界',
className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'east',
label: '拖拽右边裁剪边界',
className: 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'southEast',
label: '拖拽右下角裁剪边界',
className: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'south',
label: '拖拽下边裁剪边界',
className: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'southWest',
label: '拖拽左下角裁剪边界',
className: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'west',
label: '拖拽左边裁剪边界',
className: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
dotClassName: 'left-1/2 top-1/2',
},
];
function clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getCoverCropSizeBounds(imageSize: { width: number; height: number }) {
const maxWidth = Math.max(
1,
Math.min(imageSize.width, imageSize.height * COVER_CROP_RATIO),
);
const minWidth = Math.min(maxWidth, Math.max(48, maxWidth * 0.16));
return { minWidth, maxWidth };
}
function normalizeCoverCropRect(
cropRect: CustomWorldCoverCropRect,
imageSize: { width: number; height: number },
): CustomWorldCoverCropRect {
const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
const width = clampNumber(cropRect.width, minWidth, maxWidth);
const height = width / COVER_CROP_RATIO;
const x = clampNumber(cropRect.x, 0, Math.max(0, imageSize.width - width));
const y = clampNumber(cropRect.y, 0, Math.max(0, imageSize.height - height));
return { x, y, width, height };
}
function buildCenteredCoverCropRect(
width: number,
height: number,
): CustomWorldCoverCropRect {
const targetRatio = 16 / 9;
if (width <= 0 || height <= 0) {
return { x: 0, y: 0, width: 1, height: 1 };
}
if (width / height >= targetRatio) {
if (width / height >= COVER_CROP_RATIO) {
const cropHeight = height;
const cropWidth = cropHeight * targetRatio;
const cropWidth = cropHeight * COVER_CROP_RATIO;
return {
x: (width - cropWidth) / 2,
y: 0,
@@ -975,7 +1079,7 @@ function buildCenteredCoverCropRect(
}
const cropWidth = width;
const cropHeight = cropWidth / targetRatio;
const cropHeight = cropWidth / COVER_CROP_RATIO;
return {
x: 0,
y: (height - cropHeight) / 2,
@@ -984,16 +1088,111 @@ function buildCenteredCoverCropRect(
};
}
function clampCoverCropRect(
cropRect: CustomWorldCoverCropRect,
function resizeCoverCropRectFromHandle(
snapshot: CoverCropDragSnapshot,
deltaX: number,
deltaY: number,
imageSize: { width: number; height: number },
) {
const width = Math.max(1, Math.min(imageSize.width, cropRect.width));
const height = Math.max(1, Math.min(imageSize.height, cropRect.height));
const x = Math.max(0, Math.min(imageSize.width - width, cropRect.x));
const y = Math.max(0, Math.min(imageSize.height - height, cropRect.y));
): CustomWorldCoverCropRect {
const start = snapshot.cropRect;
const startRight = start.x + start.width;
const startBottom = start.y + start.height;
const startCenterX = start.x + start.width / 2;
const startCenterY = start.y + start.height / 2;
const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
const chooseWidth = (widthFromX: number, widthFromY: number) => {
const xDistance = Math.abs(widthFromX - start.width);
const yDistance = Math.abs(widthFromY - start.width);
return { x, y, width, height };
return xDistance >= yDistance ? widthFromX : widthFromY;
};
const clampWidth = (width: number, maxByAnchor = maxWidth) =>
clampNumber(width, minWidth, Math.max(minWidth, Math.min(maxWidth, maxByAnchor)));
if (snapshot.handle === 'move') {
return normalizeCoverCropRect(
{
...start,
x: start.x + deltaX,
y: start.y + deltaY,
},
imageSize,
);
}
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) *
COVER_CROP_RATIO;
const width = clampWidth(
start.width + (isEast ? deltaX : -deltaX),
Math.min(maxByAnchorX, maxByCenterY),
);
const height = width / COVER_CROP_RATIO;
return normalizeCoverCropRect(
{
x: isEast ? anchorX : anchorX - width,
y: startCenterY - height / 2,
width,
height,
},
imageSize,
);
}
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) * COVER_CROP_RATIO;
const maxByCenterX =
2 * Math.min(startCenterX, imageSize.width - startCenterX);
const width = clampWidth(
(start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO,
Math.min(maxByAnchorY, maxByCenterX),
);
const height = width / COVER_CROP_RATIO;
return normalizeCoverCropRect(
{
x: startCenterX - width / 2,
y: isSouth ? anchorY : anchorY - height,
width,
height,
},
imageSize,
);
}
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) * COVER_CROP_RATIO;
const widthFromX = start.width + (isEast ? deltaX : -deltaX);
const widthFromY =
(start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO;
const width = clampWidth(
chooseWidth(widthFromX, widthFromY),
Math.min(maxByAnchorX, maxByAnchorY),
);
const height = width / COVER_CROP_RATIO;
return normalizeCoverCropRect(
{
x: isEast ? anchorX : anchorX - width,
y: isSouth ? anchorY : anchorY - height,
width,
height,
},
imageSize,
);
}
function buildCoverCropPreviewStyle(
@@ -3316,51 +3515,116 @@ function buildGeneratedCoverProfile(
function CoverUploadCropModal({
imageDataUrl,
imageSize,
worldName,
isSubmitting,
onCancel,
onConfirm,
}: {
imageDataUrl: string;
imageSize: { width: number; height: number };
worldName: string;
isSubmitting: boolean;
onCancel: () => void;
onConfirm: (cropRect: CustomWorldCoverCropRect) => void;
}) {
const [zoomPercent, setZoomPercent] = useState(100);
const baseCropRect = useMemo(
() => buildCenteredCoverCropRect(imageSize.width, imageSize.height),
[imageSize],
const previewRef = useRef<HTMLDivElement | null>(null);
const dragSnapshotRef = useRef<CoverCropDragSnapshot | null>(null);
const [activeDragHandle, setActiveDragHandle] =
useState<CoverCropDragHandle | null>(null);
const [cropRect, setCropRect] = useState(() =>
normalizeCoverCropRect(
buildCenteredCoverCropRect(imageSize.width, imageSize.height),
imageSize,
),
);
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
useEffect(() => {
setZoomPercent(100);
setOffsetX(0);
setOffsetY(0);
}, [imageDataUrl]);
const cropRect = useMemo(() => {
const scale = Math.max(1, zoomPercent / 100);
const nextCropRect = {
width: baseCropRect.width / scale,
height: baseCropRect.height / scale,
x: baseCropRect.x + offsetX,
y: baseCropRect.y + offsetY,
};
return clampCoverCropRect(nextCropRect, imageSize);
}, [baseCropRect, imageSize, offsetX, offsetY, zoomPercent]);
setActiveDragHandle(null);
dragSnapshotRef.current = null;
setCropRect(
normalizeCoverCropRect(
buildCenteredCoverCropRect(imageSize.width, imageSize.height),
imageSize,
),
);
}, [imageDataUrl, imageSize]);
const previewStyle = useMemo(
() => buildCoverCropPreviewStyle(cropRect, imageSize),
[cropRect, imageSize],
);
const editorPreviewStyle = useMemo(
() =>
({
aspectRatio: `${imageSize.width} / ${imageSize.height}`,
width: `min(100%, calc(min(58vh, 34rem) * ${
imageSize.width / Math.max(1, imageSize.height)
}))`,
}) satisfies CSSProperties,
[imageSize],
);
const outputPreviewStyle = useMemo(
() =>
({
left: `${-(cropRect.x / cropRect.width) * 100}%`,
top: `${-(cropRect.y / cropRect.height) * 100}%`,
width: `${(imageSize.width / cropRect.width) * 100}%`,
height: `${(imageSize.height / cropRect.height) * 100}%`,
}) satisfies CSSProperties,
[cropRect, imageSize],
);
const maxOffsetX = Math.max(0, imageSize.width - cropRect.width);
const maxOffsetY = Math.max(0, imageSize.height - cropRect.height);
const beginCropDrag = (
handle: CoverCropDragHandle,
event: PointerEvent<HTMLElement>,
) => {
if (isSubmitting) {
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,
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);
setCropRect(resizeCoverCropRectFromHandle(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 (
<ModalShell
@@ -3371,77 +3635,75 @@ function CoverUploadCropModal({
disableClose={isSubmitting}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_20rem]">
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={imageDataUrl}
alt="上传封面裁剪预览"
fallbackLabel={worldName.slice(0, 4) || '封面'}
tone="landscape"
overlayInteractive
previewOverlay={
<>
<div className="absolute inset-0 bg-black/45" />
<div
className="absolute border border-sky-300/90 bg-white/8 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
style={previewStyle}
/>
</>
}
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="缩放">
<input
type="range"
min={100}
max={220}
step={1}
value={zoomPercent}
onChange={(event) => setZoomPercent(Number(event.target.value))}
disabled={isSubmitting}
className="w-full accent-sky-400"
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/18 p-2 sm:p-3">
<div
ref={previewRef}
aria-label="封面裁剪操作区"
className="relative mx-auto overflow-hidden rounded-xl border border-white/10 bg-black/40 select-none touch-none"
style={editorPreviewStyle}
>
<ResolvedAssetImage
src={imageDataUrl}
alt="上传封面裁剪预览"
className="h-full w-full object-fill"
draggable={false}
/>
</Field>
<Field label="左右位置">
<input
type="range"
min={0}
max={Math.max(0, Math.floor(maxOffsetX))}
step={1}
value={Math.max(0, Math.floor(offsetX + baseCropRect.x))}
onChange={(event) =>
setOffsetX(Number(event.target.value) - baseCropRect.x)
}
disabled={isSubmitting}
className="w-full accent-sky-400"
<div
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
}`}
style={previewStyle}
onPointerDown={(event) => beginCropDrag('move', event)}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
/>
</Field>
<Field label="上下位置">
<input
type="range"
min={0}
max={Math.max(0, Math.floor(maxOffsetY))}
step={1}
value={Math.max(0, Math.floor(offsetY + baseCropRect.y))}
onChange={(event) =>
setOffsetY(Number(event.target.value) - baseCropRect.y)
}
disabled={isSubmitting}
className="w-full accent-sky-400"
/>
</Field>
<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}>
{COVER_CROP_RESIZE_HANDLES.map((handleConfig) => (
<button
key={handleConfig.handle}
type="button"
aria-label={handleConfig.label}
disabled={isSubmitting}
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 sm:h-9 sm:w-9 ${handleConfig.className}`}
onPointerDown={(event) =>
beginCropDrag(handleConfig.handle, event)
}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
>
<span
className={`absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)] ${handleConfig.dotClassName}`}
/>
</button>
))}
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-6 text-zinc-200">
16:9 1600 × 900
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-xs leading-6 text-zinc-400">
<br />
{`x ${Math.round(cropRect.x)} / y ${Math.round(cropRect.y)} / w ${Math.round(cropRect.width)} / h ${Math.round(cropRect.height)}`}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-black/20 p-2">
<div className="relative aspect-[16/9] overflow-hidden rounded-xl bg-black/30">
<ResolvedAssetImage
src={imageDataUrl}
alt="上传封面裁剪结果预览"
className="absolute max-w-none object-fill"
draggable={false}
style={outputPreviewStyle}
/>
</div>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
@@ -3901,7 +4163,6 @@ export function WorldCoverEditor({
<CoverUploadCropModal
imageDataUrl={pendingUploadImageDataUrl}
imageSize={pendingUploadImageSize}
worldName={profile.name}
isSubmitting={isUploading}
onCancel={() => {
if (isUploading) {