diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index c9cc82224..a8b5bfa59 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -34,6 +34,7 @@ import type { Node as UiNode } 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 { resolvePreviewGridStep } from './previewGrid'; import { handlePreviewZoomKeyDown, isPreviewZoomInteractiveTarget, @@ -239,6 +240,7 @@ export function PreviewWorkspace({ }, [scaleViewportFromCenter]); const displayPercent = formatCanvasDisplayScalePercent(viewport.scale); + const previewGridStep = resolvePreviewGridStep(viewport.scale); const zoomToDisplayScale = useCallback( (displayScale: number) => { @@ -479,7 +481,7 @@ export function PreviewWorkspace({ 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`, + backgroundSize: `${previewGridStep * viewport.scale}px ${previewGridStep * viewport.scale}px`, }} role="region" aria-label="UI 预览画布" diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts new file mode 100644 index 000000000..d2ab93f2a --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts @@ -0,0 +1,26 @@ +/** 背景网格的基础世界步长;缩放时按 2 倍档位调整屏幕密度。 */ +export const PREVIEW_GRID_BASE_STEP = 28; +const PREVIEW_GRID_MIN_SCREEN_SPACING = 20; +const PREVIEW_GRID_MAX_SCREEN_SPACING = 40; + +/** + * 选择一个离散的世界步长,让网格圆点在屏幕上保持可读密度。 + * 背景位置仍使用 viewport 的屏幕平移量,因此切档不会破坏世界原点对齐。 + */ +export function resolvePreviewGridStep(viewportScale: number): number { + if (!Number.isFinite(viewportScale) || viewportScale <= 0) { + return PREVIEW_GRID_BASE_STEP; + } + + let step = PREVIEW_GRID_BASE_STEP; + let screenSpacing = step * viewportScale; + while (screenSpacing < PREVIEW_GRID_MIN_SCREEN_SPACING) { + step *= 2; + screenSpacing *= 2; + } + while (screenSpacing >= PREVIEW_GRID_MAX_SCREEN_SPACING) { + step /= 2; + screenSpacing /= 2; + } + return step; +} diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx index 92abe5778..6f756a6c6 100644 --- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -5,6 +5,10 @@ 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 { + PREVIEW_GRID_BASE_STEP, + resolvePreviewGridStep, +} from '../src/view/ui-editor/components/preview/previewGrid'; import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; @@ -41,6 +45,10 @@ const root: UiNode = { component: null, children_display_mode: 'Stack', children: [], + offset: { + min: [0, 0], + max: [1200, 800], + }, }; const activeImage: UIDesignImage = { @@ -62,6 +70,7 @@ function createCanvas( isLocked: false, activeImage, activeImageId: 'page-1', + uiTrees: [{ src_ui_design: 'page-1', root }], previewUrls: { 'page-1': 'data:image/png;base64,' }, images: { 'page-1': activeImage }, sprites: {}, @@ -111,6 +120,31 @@ afterEach(() => { }); describe('PreviewWorkspace quick zoom', () => { + it('chooses doubled or halved world steps to keep the grid readable', () => { + expect(resolvePreviewGridStep(1)).toBe(PREVIEW_GRID_BASE_STEP); + expect(resolvePreviewGridStep(0.5)).toBe(56); + expect(resolvePreviewGridStep(0.125)).toBe(224); + expect(resolvePreviewGridStep(2)).toBe(14); + expect(resolvePreviewGridStep(4)).toBe(7); + + for (const scale of [0.125, 0.25, 0.5, 1, 2, 4, 8]) { + const spacing = resolvePreviewGridStep(scale) * scale; + expect(spacing).toBeGreaterThanOrEqual(20); + expect(spacing).toBeLessThan(40); + } + }); + + it('applies the adaptive screen-space spacing to the preview background', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const scale = logicalViewportScale(rendered.container); + const [backgroundWidth] = preview.style.backgroundSize.split('px'); + + expect(Number(backgroundWidth)).toBeCloseTo( + resolvePreviewGridStep(scale) * scale, + ); + }); + it('uses the toolbar zoom step while the preview is hovered', () => { const rendered = render(); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md index 5d35bf013..7b21c9a35 100644 --- a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md +++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md @@ -20,6 +20,7 @@ - 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。 - 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。 - 快捷键和百分比使用逻辑 `viewport.scale` 作为缩放真相;共享 `CanvasWorld` 的渲染 transform 可能包含超采样换算,验证时不得直接把 CSS transform 值当作用户可见比例。 +- 预览画布背景继续使用圆点网格,并按 `14/28/56/112…` 世界步长的 2 倍档位自适应缩放;屏幕间距目标约为 20–40px,缩放过小时自动加倍步长、放大时自动减半步长。网格背景位置继续跟随 `viewport.x/y`,保持世界原点对齐,不改变圆点样式。 - 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。 ## 验收