e6b3199490
之前遇到几次弹窗的样式不生效,bug 现在在画布agent的删除对话弹窗又出现了, 这个pr把类似的修改集中到最下层的UnifiedModal,适用于所有的modal, 一些特殊的modal有特殊处理. 画布agent的删除对话弹窗  --------- 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>
335 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|