Files
Genarrative/src/components/common/PlatformImagePreviewModal.tsx
T
k88936 e6b3199490
Project CI / Repository checks (push) Successful in 48s
Project CI / Backend tests (push) Successful in 3m53s
Project CI / Frontend tests (push) Successful in 3m21s
Project CI / Native shell tests (push) Successful in 11m16s
Fix/统一修复modal样式失效 (#122)
之前遇到几次弹窗的样式不生效,bug 现在在画布agent的删除对话弹窗又出现了,
这个pr把类似的修改集中到最下层的UnifiedModal,适用于所有的modal,
一些特殊的modal有特殊处理.

画布agent的删除对话弹窗
![shotmd-1785399342.jpg](/attachments/d63d7dc6-bf7e-4f13-bfe8-bc4716cd798f)

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/122
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-03 17:55:47 +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>
);
}