Merge remote-tracking branch 'origin/master' into codex/editor-asset-library

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-06-17 20:52:41 +08:00
68 changed files with 2279 additions and 2676 deletions
@@ -256,7 +256,7 @@ test('creative image input panel confirms before removing uploaded image', () =>
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
});
test('creative image input panel closes reference preview on backdrop click', () => {
test('creative image input panel closes reference preview with close button', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc=""
@@ -299,8 +299,8 @@ test('creative image input panel closes reference preview on backdrop click', ()
);
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
const dialog = screen.getByRole('dialog', { name: '参考图 1' });
fireEvent.click(dialog.parentElement as HTMLElement);
expect(screen.getByRole('dialog', { name: '参考图 1' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
});
@@ -400,7 +400,12 @@ test('creative image input panel can preview the main image and keep upload on a
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
const previewDialog = screen.getByRole('dialog', {
name: '查看关卡图片',
});
expect(previewDialog.className).toContain('platform-image-preview-modal');
expect(previewDialog.className).toContain('bg-black');
expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy();
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(
2,
);
@@ -428,7 +433,7 @@ test('creative image input panel can preview the main image and keep upload on a
}
});
test('creative image input panel closes main image preview on backdrop click', () => {
test('creative image input panel closes main image preview with close button', () => {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
@@ -466,8 +471,8 @@ test('creative image input panel closes main image preview on backdrop click', (
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
fireEvent.click(dialog.parentElement as HTMLElement);
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭关卡图片预览' }));
expect(screen.queryByRole('dialog', { name: '查看关卡图片' })).toBeNull();
});
@@ -6,13 +6,13 @@ import { PlatformActionButton } from './PlatformActionButton';
import { PlatformFieldLabel } from './PlatformFieldLabel';
import { PlatformIconBadge } from './PlatformIconBadge';
import { PlatformIconButton } from './PlatformIconButton';
import { PlatformImagePreviewModal } from './PlatformImagePreviewModal';
import { PlatformPillBadge } from './PlatformPillBadge';
import { PlatformPillSwitch } from './PlatformPillSwitch';
import { PlatformStatusMessage } from './PlatformStatusMessage';
import { PlatformTextField } from './PlatformTextField';
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
import { UnifiedModal } from './UnifiedModal';
export type CreativeImageInputReferenceImage = {
id: string;
@@ -492,58 +492,28 @@ export function CreativeImageInputPanel({
</div>
) : null}
<UnifiedModal
<PlatformImagePreviewModal
open={Boolean(previewReferenceImage)}
title={previewReferenceImage?.label ?? labels.promptReferencePreviewAlt}
onClose={() => setPreviewReferenceImage(null)}
imageSrc={previewReferenceImage?.imageSrc ?? null}
imageAlt={labels.promptReferencePreviewAlt}
closeLabel={labels.closePromptReferencePreview}
closeVariant="profileCompact"
size="lg"
zIndexClassName="z-[80]"
overlayClassName="px-4 py-6"
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
titleClassName="text-sm font-black"
bodyClassName="px-0 py-0"
>
{previewReferenceImage ? (
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={previewReferenceImage.imageSrc}
alt={labels.promptReferencePreviewAlt}
className="h-full max-h-[72vh] w-full object-contain"
/>
</div>
) : null}
</UnifiedModal>
onClose={() => setPreviewReferenceImage(null)}
/>
<UnifiedModal
<PlatformImagePreviewModal
open={isMainImagePreviewOpen && Boolean(uploadedImageSrc)}
title={labels.previewMainImage ?? uploadedImageAlt}
onClose={() => setIsMainImagePreviewOpen(false)}
imageSrc={uploadedImageSrc}
imageAlt={uploadedImageAlt}
refreshKey={uploadedImageRefreshKey}
closeLabel={
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
}
closeVariant="profileCompact"
size="xl"
zIndexClassName={mainImagePreviewZIndexClassName}
overlayClassName="px-4 py-6"
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
titleClassName="text-sm font-black"
bodyClassName="px-0 py-0"
>
{uploadedImageSrc ? (
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={uploadedImageSrc}
refreshKey={uploadedImageRefreshKey}
alt={uploadedImageAlt}
className="h-full max-h-[82vh] w-full object-contain"
/>
</div>
) : null}
</UnifiedModal>
onClose={() => setIsMainImagePreviewOpen(false)}
/>
<UnifiedConfirmDialog
open={isRemoveImageConfirmOpen}
@@ -0,0 +1,181 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type React from 'react';
import { expect, test, vi } from 'vitest';
import { PlatformImagePreviewModal } from './PlatformImagePreviewModal';
import { clampPlatformImagePreviewTransform } from './platformImagePreviewModel';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
style,
draggable,
onLoad,
}: {
src?: string | null;
alt?: string;
className?: string;
style?: React.CSSProperties;
draggable?: boolean;
onLoad?: React.ReactEventHandler<HTMLImageElement>;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
style={style}
draggable={draggable}
onLoad={onLoad}
/>
) : null,
}));
test('clamps full-screen image preview zoom and drag within image bounds', () => {
expect(
clampPlatformImagePreviewTransform(
{
scale: 8,
offsetX: 999,
offsetY: -999,
},
{ width: 400, height: 800 },
{ width: 400, height: 400 },
),
).toEqual({
scale: 4,
offsetX: 600,
offsetY: -400,
});
expect(
clampPlatformImagePreviewTransform(
{
scale: 4,
offsetX: -999,
offsetY: 999,
},
{ width: 400, height: 800 },
{ width: 400, height: 400 },
),
).toEqual({
scale: 4,
offsetX: -600,
offsetY: 400,
});
expect(
clampPlatformImagePreviewTransform(
{
scale: 0.25,
offsetX: 80,
offsetY: 80,
},
{ width: 400, height: 800 },
{ width: 400, height: 400 },
),
).toEqual({
scale: 1,
offsetX: 0,
offsetY: 0,
});
});
test('renders full-screen image preview with zoom controls and dark backdrop', () => {
render(
<PlatformImagePreviewModal
open
title="查看关卡图片"
imageSrc="/generated-puzzle-assets/session/level/image.png"
imageAlt="拼图关卡图"
closeLabel="关闭关卡图片预览"
onClose={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
expect(dialog.className).toContain('platform-image-preview-modal');
expect(dialog.className).toContain('bg-black');
expect(
dialog.parentElement?.className.includes('!bg-black'),
).toBe(true);
expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy();
expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy();
expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy();
expect(screen.getByAltText('拼图关卡图').getAttribute('draggable')).toBe(
'false',
);
});
test('zooms to at most four times and clamps dragged image position', async () => {
render(
<PlatformImagePreviewModal
open
title="查看关卡图片"
imageSrc="/generated-puzzle-assets/session/level/image.png"
imageAlt="拼图关卡图"
closeLabel="关闭关卡图片预览"
onClose={() => {}}
/>,
);
const stage = screen.getByTestId('platform-image-preview-stage');
Object.defineProperty(stage, 'getBoundingClientRect', {
configurable: true,
value: () => ({
width: 400,
height: 800,
top: 0,
right: 400,
bottom: 800,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}),
});
const image = screen.getByAltText('拼图关卡图') as HTMLImageElement;
Object.defineProperties(image, {
naturalWidth: { configurable: true, value: 400 },
naturalHeight: { configurable: true, value: 400 },
});
await act(async () => {
window.dispatchEvent(new Event('resize'));
fireEvent.load(image);
});
await waitFor(() => {
expect(image.style.width).toBe('400px');
expect(image.style.height).toBe('400px');
});
for (let index = 0; index < 8; index += 1) {
fireEvent.click(screen.getByRole('button', { name: '放大图片' }));
}
await waitFor(() => {
expect(image.style.transform).toContain(
'translate3d(0px, 0px, 0) scale(4)',
);
});
fireEvent.pointerDown(stage, { pointerId: 1, clientX: 200, clientY: 400 });
fireEvent.pointerMove(stage, { pointerId: 1, clientX: 1200, clientY: 1200 });
fireEvent.pointerUp(stage, { pointerId: 1, clientX: 1200, clientY: 1200 });
await waitFor(() => {
const offsetMatch = image.style.transform.match(
/translate3d\((-?\d+)px, (-?\d+)px, 0\) scale\(4\)/u,
);
expect(offsetMatch).not.toBeNull();
expect(Math.abs(Number(offsetMatch?.[1]))).toBe(600);
expect(Math.abs(Number(offsetMatch?.[2]))).toBe(400);
});
});
@@ -0,0 +1,332 @@
import { Minus, Plus, RotateCcw } from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformIconButton } from './PlatformIconButton';
import {
clampPlatformImagePreviewTransform,
DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM,
MAX_PLATFORM_IMAGE_PREVIEW_SCALE,
MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
PLATFORM_IMAGE_PREVIEW_SCALE_STEP,
type PlatformImagePreviewSize,
type PlatformImagePreviewTransform,
resolvePlatformImagePreviewContainedSize,
samePlatformImagePreviewTransform,
} from './platformImagePreviewModel';
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
import { UnifiedModal } from './UnifiedModal';
type PlatformImagePreviewModalProps = {
open: boolean;
title: string;
imageSrc: string | null;
imageAlt: string;
refreshKey?: string | number | null;
closeLabel: string;
zIndexClassName?: string;
onClose: () => void;
};
type DragState = {
pointerId: number;
startClientX: number;
startClientY: number;
startOffsetX: number;
startOffsetY: number;
scale: number;
};
/**
* 全屏图片查看器。
* 1x 保持完整图片可见;放大后拖拽会按图片边界夹住,避免拖出空背景。
*/
export function PlatformImagePreviewModal({
open,
title,
imageSrc,
imageAlt,
refreshKey = null,
closeLabel,
zIndexClassName = 'z-[110]',
onClose,
}: PlatformImagePreviewModalProps) {
const stageRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const [viewportSize, setViewportSize] = useState<PlatformImagePreviewSize>({
width: 1,
height: 1,
});
const [naturalSize, setNaturalSize] =
useState<PlatformImagePreviewSize | null>(null);
const [transform, setTransform] = useState<PlatformImagePreviewTransform>(
DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM,
);
const [isDragging, setIsDragging] = useState(false);
const containedImageSize = useMemo(
() => resolvePlatformImagePreviewContainedSize(viewportSize, naturalSize),
[naturalSize, viewportSize],
);
const clampTransform = useCallback(
(nextTransform: PlatformImagePreviewTransform) =>
clampPlatformImagePreviewTransform(
nextTransform,
viewportSize,
naturalSize,
),
[naturalSize, viewportSize],
);
const updateViewportSize = useCallback(() => {
const rect = stageRef.current?.getBoundingClientRect();
setViewportSize({
width: rect?.width || window.innerWidth || 1,
height: rect?.height || window.innerHeight || 1,
});
}, []);
const updateTransform = useCallback(
(
updater: (
current: PlatformImagePreviewTransform,
) => PlatformImagePreviewTransform,
) => {
setTransform((current) => {
const nextTransform = clampTransform(updater(current));
return samePlatformImagePreviewTransform(current, nextTransform)
? current
: nextTransform;
});
},
[clampTransform],
);
useEffect(() => {
if (!open) {
return;
}
setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM);
setNaturalSize(null);
dragStateRef.current = null;
setIsDragging(false);
}, [imageSrc, open, refreshKey]);
useEffect(() => {
if (!open) {
return;
}
updateViewportSize();
const frameId = window.requestAnimationFrame(updateViewportSize);
window.addEventListener('resize', updateViewportSize);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', updateViewportSize);
};
}, [open, updateViewportSize]);
useEffect(() => {
setTransform((current) => {
const nextTransform = clampTransform(current);
return samePlatformImagePreviewTransform(current, nextTransform)
? current
: nextTransform;
});
}, [clampTransform]);
const zoomBy = useCallback(
(scaleDelta: number) => {
updateTransform((current) => ({
...current,
scale: current.scale + scaleDelta,
}));
},
[updateTransform],
);
const resetTransform = useCallback(() => {
setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM);
}, []);
const handleWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement>) => {
event.preventDefault();
zoomBy(
event.deltaY < 0
? PLATFORM_IMAGE_PREVIEW_SCALE_STEP
: -PLATFORM_IMAGE_PREVIEW_SCALE_STEP,
);
},
[zoomBy],
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (transform.scale <= MIN_PLATFORM_IMAGE_PREVIEW_SCALE) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
dragStateRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startOffsetX: transform.offsetX,
startOffsetY: transform.offsetY,
scale: transform.scale,
};
setIsDragging(true);
},
[transform],
);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const deltaX = event.clientX - dragState.startClientX;
const deltaY = event.clientY - dragState.startClientY;
updateTransform(() => ({
scale: dragState.scale,
offsetX: dragState.startOffsetX + deltaX,
offsetY: dragState.startOffsetY + deltaY,
}));
},
[updateTransform],
);
const finishPointerDrag = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (dragStateRef.current?.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
dragStateRef.current = null;
setIsDragging(false);
},
[],
);
const imageStyle = {
width: `${containedImageSize.width}px`,
height: `${containedImageSize.height}px`,
transform: `translate3d(${transform.offsetX}px, ${transform.offsetY}px, 0) scale(${transform.scale})`,
};
const canZoomOut = transform.scale > MIN_PLATFORM_IMAGE_PREVIEW_SCALE;
const canZoomIn = transform.scale < MAX_PLATFORM_IMAGE_PREVIEW_SCALE;
return (
<UnifiedModal
open={open && Boolean(imageSrc)}
title={title}
ariaLabel={title}
onClose={onClose}
closeLabel={closeLabel}
closeOnBackdrop={false}
showHeader={false}
showCloseButton={false}
size="fullscreen"
zIndexClassName={zIndexClassName}
overlayClassName="!items-stretch !justify-stretch !bg-black !p-0 !backdrop-blur-none"
panelClassName="platform-image-preview-modal !h-[100dvh] !max-h-none !max-w-none !rounded-none border-0 bg-black text-white shadow-none"
bodyClassName="relative flex h-full min-h-0 flex-col !overflow-hidden !p-0"
>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 flex items-center justify-between gap-3 px-4 py-[max(0.85rem,env(safe-area-inset-top))]">
<div className="pointer-events-auto flex items-center gap-2">
<PlatformIconButton
variant="darkMini"
label="缩小图片"
title="缩小图片"
disabled={!canZoomOut}
icon={<Minus className="h-4 w-4" />}
onClick={() => zoomBy(-PLATFORM_IMAGE_PREVIEW_SCALE_STEP)}
className="h-9 w-9"
/>
<PlatformIconButton
variant="darkMini"
label="重置图片缩放"
title="重置图片缩放"
disabled={!canZoomOut}
icon={<RotateCcw className="h-4 w-4" />}
onClick={resetTransform}
className="h-9 w-9"
/>
<PlatformIconButton
variant="darkMini"
label="放大图片"
title="放大图片"
disabled={!canZoomIn}
icon={<Plus className="h-4 w-4" />}
onClick={() => zoomBy(PLATFORM_IMAGE_PREVIEW_SCALE_STEP)}
className="h-9 w-9"
/>
</div>
<PlatformModalCloseButton
variant="editorDark"
placement="inline"
label={closeLabel}
onClick={onClose}
className="pointer-events-auto h-9 w-9"
/>
</div>
<div
ref={stageRef}
data-testid="platform-image-preview-stage"
className={[
'relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black',
transform.scale > MIN_PLATFORM_IMAGE_PREVIEW_SCALE
? isDragging
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-default',
]
.filter(Boolean)
.join(' ')}
style={{ touchAction: 'none' }}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishPointerDrag}
onPointerCancel={finishPointerDrag}
>
{imageSrc ? (
<ResolvedAssetImage
src={imageSrc}
refreshKey={refreshKey}
alt={imageAlt}
draggable={false}
onLoad={(event) => {
const image = event.currentTarget;
setNaturalSize({
width: image.naturalWidth || image.clientWidth || 1,
height: image.naturalHeight || image.clientHeight || 1,
});
}}
className={`max-w-none select-none object-contain transition-transform ease-out ${
isDragging ? 'duration-0' : 'duration-150'
}`}
style={imageStyle}
/>
) : null}
</div>
</UnifiedModal>
);
}
@@ -0,0 +1,96 @@
export type PlatformImagePreviewSize = {
width: number;
height: number;
};
export type PlatformImagePreviewTransform = {
scale: number;
offsetX: number;
offsetY: number;
};
export const MIN_PLATFORM_IMAGE_PREVIEW_SCALE = 1;
export const MAX_PLATFORM_IMAGE_PREVIEW_SCALE = 4;
export const PLATFORM_IMAGE_PREVIEW_SCALE_STEP = 0.5;
export const DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM: PlatformImagePreviewTransform =
{
scale: MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
offsetX: 0,
offsetY: 0,
};
function clampNumber(value: number, min: number, max: number) {
if (!Number.isFinite(value)) {
return min;
}
return Math.max(min, Math.min(max, value));
}
function normalizePreviewSize(size: PlatformImagePreviewSize | null) {
if (!size || size.width <= 0 || size.height <= 0) {
return { width: 1, height: 1 };
}
return size;
}
export function resolvePlatformImagePreviewContainedSize(
viewportSize: PlatformImagePreviewSize,
naturalSize: PlatformImagePreviewSize | null,
) {
const viewport = normalizePreviewSize(viewportSize);
const natural = normalizePreviewSize(naturalSize);
const imageRatio = natural.width / natural.height;
const viewportRatio = viewport.width / viewport.height;
if (imageRatio > viewportRatio) {
return {
width: viewport.width,
height: viewport.width / imageRatio,
};
}
return {
width: viewport.height * imageRatio,
height: viewport.height,
};
}
export function samePlatformImagePreviewTransform(
left: PlatformImagePreviewTransform,
right: PlatformImagePreviewTransform,
) {
return (
left.scale === right.scale &&
left.offsetX === right.offsetX &&
left.offsetY === right.offsetY
);
}
export function clampPlatformImagePreviewTransform(
transform: PlatformImagePreviewTransform,
viewportSize: PlatformImagePreviewSize,
naturalSize: PlatformImagePreviewSize | null,
): PlatformImagePreviewTransform {
const viewport = normalizePreviewSize(viewportSize);
const containedSize = resolvePlatformImagePreviewContainedSize(
viewport,
naturalSize,
);
const scale = clampNumber(
transform.scale,
MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
MAX_PLATFORM_IMAGE_PREVIEW_SCALE,
);
const scaledWidth = containedSize.width * scale;
const scaledHeight = containedSize.height * scale;
const panLimitX = Math.max(0, (scaledWidth - viewport.width) / 2);
const panLimitY = Math.max(0, (scaledHeight - viewport.height) / 2);
return {
scale,
offsetX: clampNumber(transform.offsetX, -panLimitX, panLimitX),
offsetY: clampNumber(transform.offsetY, -panLimitY, panLimitY),
};
}