接入预览画布加减快捷缩放

复用工具栏中心缩放逻辑并统一加减倍率常量
为预览画布增加悬停与键盘焦点作用域
保留既有适配画布和实际尺寸快捷键行为
This commit is contained in:
2026-09-05 19:49:27 +08:00
parent af8e1df139
commit df72dd4bed
4 changed files with 290 additions and 33 deletions
@@ -1,4 +1,6 @@
import {
CANVAS_ZOOM_IN_FACTOR,
CANVAS_ZOOM_OUT_FACTOR,
type CanvasViewport,
createPanDragState,
type DragState,
@@ -28,6 +30,11 @@ import type { Node } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
import {
handlePreviewZoomKeyDown,
isPreviewZoomInteractiveTarget,
previewZoomUsesMetaModifier,
} from './previewZoomKeyboard';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
@@ -41,6 +48,8 @@ export function PreviewWorkspace({
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
const handledFocusRequestIdRef = useRef<number | null>(null);
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
const previewFocusedRef = useRef(false);
const previewHoveredRef = useRef(false);
const [viewport, setViewportState] = useState<CanvasViewport>(
viewportRef.current,
);
@@ -150,6 +159,34 @@ export function PreviewWorkspace({
);
}, [logicalSize, setViewport]);
const scaleViewportFromCenter = useCallback(
(nextScale: number) => {
const element = viewportElementRef.current;
const width = element?.clientWidth || canvasSize.width;
const height = element?.clientHeight || canvasSize.height;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
},
[canvasSize.height, canvasSize.width, setViewport],
);
const resetToActualSize = useCallback(() => {
scaleViewportFromCenter(1);
}, [scaleViewportFromCenter]);
const zoomIn = useCallback(() => {
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR);
}, [scaleViewportFromCenter]);
const zoomOut = useCallback(() => {
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR);
}, [scaleViewportFromCenter]);
useEffect(() => {
const element = viewportElementRef.current;
if (!element) return;
@@ -224,6 +261,9 @@ export function PreviewWorkspace({
),
);
};
const usesMetaModifier = previewZoomUsesMetaModifier(
window.navigator.platform,
);
const onKeyDown = (event: KeyboardEvent) => {
if (
event.code === 'Space' &&
@@ -232,23 +272,21 @@ export function PreviewWorkspace({
) {
setSpaceHeld(true);
}
if (!event.ctrlKey && !event.metaKey) return;
if (event.key === '0') {
event.preventDefault();
fitToCanvas();
} else if (event.key === '1' && logicalSize) {
event.preventDefault();
const element = viewportElementRef.current;
const width = element?.clientWidth ?? 900;
const height = element?.clientHeight ?? 640;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale: 1,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
}
handlePreviewZoomKeyDown(
event,
{
hasZoomableViewport: logicalSize !== null,
isFocused: previewFocusedRef.current,
isHovered: previewHoveredRef.current,
usesMetaModifier,
},
{
fit: fitToCanvas,
resetToActualSize,
zoomIn,
zoomOut,
},
);
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') setSpaceHeld(false);
@@ -262,9 +300,12 @@ export function PreviewWorkspace({
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onWindowBlur);
};
}, [fitToCanvas, logicalSize, setViewport]);
}, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) {
event.currentTarget.focus({ preventScroll: true });
}
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
@@ -371,7 +412,27 @@ export function PreviewWorkspace({
</div>
<SharedCanvasViewport
ref={viewportElementRef}
className="size-full"
className="size-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-orange-400"
role="region"
aria-label="UI 预览画布"
tabIndex={0}
onPointerEnter={() => {
previewHoveredRef.current = true;
}}
onPointerLeave={() => {
previewHoveredRef.current = false;
}}
onFocus={() => {
previewFocusedRef.current = true;
}}
onBlur={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
previewFocusedRef.current = false;
}
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -448,18 +509,7 @@ export function PreviewWorkspace({
<ZoomControls
viewport={viewport}
onFit={fitToCanvas}
onScaleFromCenter={(nextScale) => {
const element = viewportElementRef.current;
const width = element?.clientWidth ?? canvasSize.width;
const height = element?.clientHeight ?? canvasSize.height;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
}}
onScaleFromCenter={scaleViewportFromCenter}
>
{(actions) => (
<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">
@@ -0,0 +1,203 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage';
import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace';
import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage';
class TestResizeObserver {
observe() {}
disconnect() {}
unobserve() {}
}
const root: UiNode = {
id: 'root',
layout: {
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
custom_minimum_size: [0, 0],
size_flags_horizontal: 1,
size_flags_vertical: 1,
size_flags_stretch_ratio: 1,
container: 'None',
},
metadata: {
name: '根节点',
description: '',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children: [],
};
const activeImage: UIDesignImage = {
metadata: {
name: '测试界面',
description: '',
role: null,
slave_to: null,
},
path: 'assets/page.png',
pixel_size: [1200, 800],
pixels_per_unit: 1,
};
function createCanvas(
overrides: Partial<UiEditorCanvasProjection> = {},
): UiEditorCanvasProjection {
return {
isLocked: false,
activeImage,
activeImageId: 'page-1',
previewUrls: { 'page-1': 'data:image/png;base64,' },
images: { 'page-1': activeImage },
sprites: {},
fontFaces: {},
tree: { src_ui_design: 'page-1', root },
selectedNode: null,
selectedNodeId: null,
keepChildrenUnchanged: false,
hiddenNodeIds: new Set(),
focusRequest: null,
status: null,
isNodePreviewVisible: vi.fn(() => true),
toggleNodePreviewVisibility: vi.fn(),
selectExclusiveChild: vi.fn(),
selectNode: vi.fn(),
clearNodeSelection: vi.fn(),
updateNodeTransform: vi.fn(),
insertNode: vi.fn(),
insertNodeAfter: vi.fn(),
deleteNode: vi.fn(),
openClearDialog: vi.fn(),
...overrides,
};
}
function renderedScale(container: HTMLElement) {
const transform = (
container.querySelector('.genarrative-image-canvas__world') as HTMLElement
).style.transform;
const scale = /scale\(([^)]+)\)/.exec(transform)?.[1];
if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`);
return Number(scale);
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', TestResizeObserver);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('PreviewWorkspace quick zoom', () => {
it('uses the toolbar zoom step while the preview is hovered', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const before = renderedScale(rendered.container);
fireEvent.pointerEnter(preview);
const wasNotCancelled = fireEvent.keyDown(window, {
key: '=',
code: 'Equal',
ctrlKey: true,
cancelable: true,
});
expect(wasNotCancelled).toBe(false);
expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16);
});
it('requires hover or focus and ignores interactive targets', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const zoomButton = screen.getByRole('button', { name: '放大画布' });
const initial = renderedScale(rendered.container);
expect(
fireEvent.keyDown(window, {
key: '=',
ctrlKey: true,
cancelable: true,
}),
).toBe(true);
expect(renderedScale(rendered.container)).toBe(initial);
fireEvent.pointerEnter(preview);
expect(
fireEvent.keyDown(zoomButton, {
key: '=',
ctrlKey: true,
cancelable: true,
}),
).toBe(true);
expect(renderedScale(rendered.container)).toBe(initial);
fireEvent.pointerLeave(preview);
fireEvent.focus(preview);
fireEvent.keyDown(window, {
key: '-',
code: 'Minus',
ctrlKey: true,
cancelable: true,
});
expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86);
});
it('keeps actual-size and fit shortcuts within the preview scope', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const fitted = renderedScale(rendered.container);
fireEvent.focus(preview);
fireEvent.keyDown(window, {
key: '1',
ctrlKey: true,
cancelable: true,
});
expect(renderedScale(rendered.container)).toBe(1);
fireEvent.keyDown(window, {
key: '0',
ctrlKey: true,
cancelable: true,
});
expect(renderedScale(rendered.container)).toBe(fitted);
});
it('leaves browser zoom untouched when the preview has no content', () => {
render(
<PreviewWorkspace
canvas={createCanvas({
activeImage: null,
activeImageId: null,
tree: null,
})}
/>,
);
expect(
fireEvent.keyDown(window, {
key: '=',
ctrlKey: true,
cancelable: true,
}),
).toBe(true);
});
});
+2
View File
@@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000;
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
export const MIN_SCALE = 0.025;
export const MAX_SCALE = 3.2;
export const CANVAS_ZOOM_IN_FACTOR = 1.16;
export const CANVAS_ZOOM_OUT_FACTOR = 0.86;
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const FIT_VIEW_PADDING = 10;
@@ -1,4 +1,6 @@
import {
CANVAS_ZOOM_IN_FACTOR,
CANVAS_ZOOM_OUT_FACTOR,
canvasDisplayScaleToViewportScale,
type CanvasViewport,
formatCanvasDisplayScalePercent,
@@ -27,8 +29,8 @@ export function ZoomControls({
const actions: CanvasZoomActions = {
displayPercent: formatCanvasDisplayScalePercent(viewport.scale),
fit: onFit,
zoomIn: () => onScaleFromCenter(viewport.scale * 1.16),
zoomOut: () => onScaleFromCenter(viewport.scale * 0.86),
zoomIn: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_IN_FACTOR),
zoomOut: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_OUT_FACTOR),
zoomToDisplayScale: (displayScale) =>
onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)),
};