接入预览画布加减快捷缩放
复用工具栏中心缩放逻辑并统一加减倍率常量 为预览画布增加悬停与键盘焦点作用域 保留既有适配画布和实际尺寸快捷键行为
This commit is contained in:
+81
-31
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user