修复 UI 预览画布交互

为无限视口添加跟随平移和缩放的点阵背景

恢复画布快捷缩放与初始适配比例控制

保留节点平移选择并屏蔽浏览器原生拖拽选择
This commit is contained in:
2026-09-12 20:47:06 +08:00
parent 5e1e5a2e5f
commit bd4dcfeacf
@@ -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>
);
}