Files
Genarrative/src/components/common/PlatformImagePreviewModal.tsx
T
k88936 bd4baf66b1
Project CI / Repository checks (pull_request) Successful in 1m18s
Project CI / Backend tests (pull_request) Successful in 3m59s
Project CI / Native shell tests (pull_request) Successful in 10m52s
Project CI / Frontend tests (pull_request) Successful in 4m19s
统一修复Modal Portal主题继承
在UnifiedModal集中解析Portal主题并支持显式覆盖
梳理危险确认、图片预览、报告和工具弹窗的主题边界
修复固定浅色画布弹窗与Agent菜单的主题回归
将相关公共组件测试纳入默认Vitest收集范围
同步Platform UI Kit、画布Agent与共享记忆文档
2026-07-31 20:14:58 +08:00

335 lines
9.9 KiB
TypeScript

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"
// 全黑自绘查看器不需要平台变量;避免 light / dark remap 改写黑底、白字和暗色控件。
portalTheme="none"
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>
);
}