修复 UI 预览画布交互
为无限视口添加跟随平移和缩放的点阵背景 恢复画布快捷缩放与初始适配比例控制 保留节点平移选择并屏蔽浏览器原生拖拽选择
This commit is contained in:
+70
-74
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CANVAS_WORLD_SIZE,
|
||||
CANVAS_ZOOM_IN_FACTOR,
|
||||
CANVAS_ZOOM_OUT_FACTOR,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
@@ -40,12 +41,41 @@ import {
|
||||
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
|
||||
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
|
||||
|
||||
/** 画布初始适配比例;调小可为浮动面板预留更多可视空间。 */
|
||||
export const UI_EDITOR_FIT_DEGREE = 0.7;
|
||||
|
||||
function fitEditorViewport(
|
||||
bounds: { x: number; y: number; width: number; height: number },
|
||||
canvasSize: { width: number; height: number },
|
||||
): CanvasViewport {
|
||||
const fit = fitViewportToBounds({ bounds, canvasSize });
|
||||
const scale = fit.scale * UI_EDITOR_FIT_DEGREE;
|
||||
return {
|
||||
scale,
|
||||
x: canvasSize.width / 2 - (bounds.x + bounds.width / 2) * scale,
|
||||
y: canvasSize.height / 2 - (bounds.y + bounds.height / 2) * scale,
|
||||
};
|
||||
}
|
||||
|
||||
export type { UiEditorRenderMode } from './UiTreeRenderer';
|
||||
|
||||
export function PreviewWorkspace({
|
||||
canvas,
|
||||
renderMode: controlledRenderMode,
|
||||
onRenderModeChange: _onRenderModeChange,
|
||||
showFrame: controlledShowFrame,
|
||||
onShowFrameChange: _onShowFrameChange,
|
||||
}: {
|
||||
canvas: UiEditorCanvasProjection;
|
||||
renderMode?: UiEditorRenderMode;
|
||||
onRenderModeChange?: (mode: UiEditorRenderMode) => void;
|
||||
showFrame?: boolean;
|
||||
onShowFrameChange?: (show: boolean) => void;
|
||||
}) {
|
||||
const { activeImage, activeImageId, previewUrls } = canvas;
|
||||
const activePreviewUrl = activeImageId
|
||||
? previewUrls[activeImageId]
|
||||
: undefined;
|
||||
const viewportElementRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
|
||||
const handledFocusRequestIdRef = useRef<number | null>(null);
|
||||
@@ -58,9 +88,8 @@ export function PreviewWorkspace({
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
|
||||
const canvasSizeRef = useRef(canvasSize);
|
||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
||||
const [renderMode, setRenderMode] =
|
||||
useState<UiEditorRenderMode>('editor-overlay');
|
||||
const [showFrame, setShowFrame] = useState(false);
|
||||
const renderMode = controlledRenderMode ?? 'editor-overlay';
|
||||
const showFrame = controlledShowFrame ?? false;
|
||||
const [previewTransforms, setPreviewTransforms] = useState<
|
||||
ReadonlyMap<NodeId, Node['layout']['transform']>
|
||||
>(new Map());
|
||||
@@ -146,25 +175,37 @@ export function PreviewWorkspace({
|
||||
if (!logicalSize) return;
|
||||
const element = viewportElementRef.current;
|
||||
setViewport(
|
||||
fitViewportToBounds({
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: logicalSize.width,
|
||||
height: logicalSize.height,
|
||||
},
|
||||
canvasSize: {
|
||||
fitEditorViewport(
|
||||
{ x: 0, y: 0, width: logicalSize.width, height: logicalSize.height },
|
||||
{
|
||||
width: element?.clientWidth || canvasSizeRef.current.width,
|
||||
height: element?.clientHeight || canvasSizeRef.current.height,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, [logicalSize, setViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeImageId || !logicalSize) return;
|
||||
fitToCanvas();
|
||||
}, [activeImageId, fitToCanvas, logicalSize]);
|
||||
const element = viewportElementRef.current;
|
||||
const canvasSize = {
|
||||
width: element?.clientWidth || canvasSizeRef.current.width,
|
||||
height: element?.clientHeight || canvasSizeRef.current.height,
|
||||
};
|
||||
setViewport(
|
||||
fitEditorViewport(
|
||||
{ x: 0, y: 0, width: logicalSize.width, height: logicalSize.height },
|
||||
canvasSize,
|
||||
),
|
||||
);
|
||||
}, [
|
||||
activeImageId,
|
||||
activePreviewUrl,
|
||||
canvasSize.height,
|
||||
canvasSize.width,
|
||||
logicalSize,
|
||||
setViewport,
|
||||
]);
|
||||
|
||||
const scaleViewportFromCenter = useCallback(
|
||||
(nextScale: number) => {
|
||||
@@ -243,15 +284,15 @@ export function PreviewWorkspace({
|
||||
};
|
||||
handledFocusRequestIdRef.current = request.requestId;
|
||||
setViewport(
|
||||
fitViewportToBounds({
|
||||
bounds: {
|
||||
fitEditorViewport(
|
||||
{
|
||||
x: context.rect.min[0],
|
||||
y: context.rect.min[1],
|
||||
width: context.rect.max[0] - context.rect.min[0],
|
||||
height: context.rect.max[1] - context.rect.min[1],
|
||||
},
|
||||
canvasSize: size,
|
||||
}),
|
||||
size,
|
||||
),
|
||||
);
|
||||
}, [
|
||||
activeImageId,
|
||||
@@ -385,45 +426,18 @@ export function PreviewWorkspace({
|
||||
}, [renderMode]);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--platform-body-fill)">
|
||||
<section className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-[#f8f4ef]">
|
||||
{activeImage &&
|
||||
logicalSize &&
|
||||
(renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className="pointer-events-auto absolute right-3 top-3 z-40 flex max-w-[calc(100%-1.5rem)] flex-wrap items-center justify-end gap-2 bg-transparent text-[10px]">
|
||||
{renderMode === 'final-preview' ? (
|
||||
<label className="inline-flex cursor-pointer select-none items-center gap-1.5 rounded-lg bg-white/70 px-2 py-1 text-(--platform-text-soft) shadow-sm backdrop-blur">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showFrame}
|
||||
onChange={(event) => setShowFrame(event.target.checked)}
|
||||
className="size-3 accent-orange-500"
|
||||
/>
|
||||
<span>显示框线</span>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="flex items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 p-0.5 shadow-sm backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-2 py-1 ${renderMode === 'editor-overlay' ? 'bg-blue-600 text-white' : 'text-(--platform-text-soft)'}`}
|
||||
aria-pressed={renderMode === 'editor-overlay'}
|
||||
onClick={() => setRenderMode('editor-overlay')}
|
||||
>
|
||||
编辑叠加
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-2 py-1 ${renderMode === 'final-preview' ? 'bg-orange-500 text-white' : 'text-(--platform-text-soft)'}`}
|
||||
aria-pressed={renderMode === 'final-preview'}
|
||||
onClick={() => setRenderMode('final-preview')}
|
||||
>
|
||||
最终预览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SharedCanvasViewport
|
||||
ref={viewportElementRef}
|
||||
className="size-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-orange-400"
|
||||
className="size-full select-none outline-none bg-[radial-gradient(#b8aaa0_1.5px,transparent_1.5px)]"
|
||||
style={{
|
||||
backgroundPosition: `${viewport.x}px ${viewport.y}px`,
|
||||
backgroundSize: `${28 * viewport.scale}px ${28 * viewport.scale}px`,
|
||||
}}
|
||||
role="region"
|
||||
aria-label="UI 预览画布"
|
||||
tabIndex={0}
|
||||
@@ -449,16 +463,14 @@ export function PreviewWorkspace({
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
if (!(event.target as Element).closest('[data-node-id]')) {
|
||||
canvas.clearNodeSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CanvasWorld
|
||||
viewport={viewport}
|
||||
worldSize={Math.max(logicalSize.width, logicalSize.height)}
|
||||
>
|
||||
<CanvasWorld viewport={viewport} worldSize={CANVAS_WORLD_SIZE}>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: logicalSize.width, height: logicalSize.height }}
|
||||
@@ -467,7 +479,7 @@ export function PreviewWorkspace({
|
||||
<img
|
||||
src={previewUrls[activeImageId ?? '']}
|
||||
alt={activeImage.metadata.name}
|
||||
className="absolute inset-0 size-full object-fill shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
|
||||
className="pointer-events-none absolute inset-0 size-full object-fill shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
@@ -518,7 +530,7 @@ export function PreviewWorkspace({
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"
|
||||
className="absolute bottom-4 left-[calc(50%+390px)] z-10 flex select-none items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"
|
||||
onMouseDown={(event) => {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
@@ -527,6 +539,7 @@ export function PreviewWorkspace({
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -582,23 +595,6 @@ export function PreviewWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<footer className="flex min-h-14 shrink-0 items-center gap-2 border-t border-(--platform-subpanel-border) px-4 py-2">
|
||||
{canvas.status ? (
|
||||
<span className="mr-auto text-xs text-(--platform-text-soft)">
|
||||
{canvas.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mr-auto" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs disabled:opacity-40"
|
||||
disabled={canvas.isLocked}
|
||||
onClick={canvas.openClearDialog}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user