diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts index f1905e3d4..e28b3b8ce 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts @@ -1,4 +1,6 @@ import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; +import type { ResizeAxis } from './proportionalResize'; +import { resolveProportionalResize } from './proportionalResize'; export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; @@ -76,6 +78,7 @@ export function resizePageRect( handle: ResizeHandle, delta: [number, number], keepRatio: boolean, + ratioAxis?: ResizeAxis, ): PageRect { const next: PageRect = { min: [startRect.min[0], startRect.min[1]], @@ -105,25 +108,17 @@ export function resizePageRect( } if (keepRatio && handle.length === 2) { - const startWidth = Math.max( - MIN_NODE_SIZE, - startRect.max[0] - startRect.min[0], - ); - const startHeight = Math.max( - MIN_NODE_SIZE, - startRect.max[1] - startRect.min[1], - ); - const ratio = startWidth / startHeight; - const rawWidth = Math.max( - MIN_NODE_SIZE, - Math.abs(next.max[0] - next.min[0]), - ); - const rawHeight = Math.max( - MIN_NODE_SIZE, - Math.abs(next.max[1] - next.min[1]), - ); - const width = Math.max(MIN_NODE_SIZE, rawWidth, rawHeight * ratio); - const height = Math.max(MIN_NODE_SIZE, width / ratio); + const { + size: [width, height], + } = resolveProportionalResize({ + startSize: [ + startRect.max[0] - startRect.min[0], + startRect.max[1] - startRect.min[1], + ], + size: [next.max[0] - next.min[0], next.max[1] - next.min[1]], + delta, + axis: ratioAxis, + }); if (handle.includes('w')) next.min[0] = startRect.max[0] - width; else next.max[0] = startRect.min[0] + width; if (handle.includes('n')) next.min[1] = startRect.max[1] - height; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/proportionalResize.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/proportionalResize.ts new file mode 100644 index 000000000..ee8ea28ca --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/proportionalResize.ts @@ -0,0 +1,46 @@ +export type ResizeAxis = 'horizontal' | 'vertical'; + +const MIN_SIZE = 1; + +export type ProportionalResizeInput = { + startSize: [number, number]; + size: [number, number]; + delta: [number, number]; + axis?: ResizeAxis; +}; + +export type ProportionalResizeResult = { + axis: ResizeAxis; + size: [number, number]; +}; + +export function resolveProportionalResizeAxis( + startSize: [number, number], + delta: [number, number], +): ResizeAxis { + const startWidth = Math.max(MIN_SIZE, startSize[0]); + const startHeight = Math.max(MIN_SIZE, startSize[1]); + return Math.abs(delta[0]) / startWidth >= Math.abs(delta[1]) / startHeight + ? 'horizontal' + : 'vertical'; +} + +export function resolveProportionalResize({ + startSize, + size, + delta, + axis, +}: ProportionalResizeInput): ProportionalResizeResult { + const startWidth = Math.max(MIN_SIZE, startSize[0]); + const startHeight = Math.max(MIN_SIZE, startSize[1]); + const resolvedAxis = axis ?? resolveProportionalResizeAxis(startSize, delta); + const ratio = startWidth / startHeight; + const width = + resolvedAxis === 'horizontal' + ? Math.max(MIN_SIZE, size[0]) + : Math.max(MIN_SIZE, size[1]) * ratio; + return { + axis: resolvedAxis, + size: [width, width / ratio], + }; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 36b03cd38..3393e9863 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -15,6 +15,10 @@ import { resizePageRect, setOffsetsForPageRect, } from './nodeTransformGeometry'; +import { + type ResizeAxis, + resolveProportionalResizeAxis, +} from './proportionalResize'; type ViewportScale = { scale: number }; @@ -36,6 +40,7 @@ type NodeResizeState = { startTransform: UiNode['layout']['transform']; startRect: PageRect; parentRect: PageRect; + ratioAxis: ResizeAxis | null; hasMoved: boolean; }; @@ -163,6 +168,7 @@ export function useNodeTransformInteraction({ startTransform: structuredClone(node.layout.transform), startRect: context.rect, parentRect: context.parentRect, + ratioAxis: null, hasMoved: false, }; }, @@ -184,14 +190,25 @@ export function useNodeTransformInteraction({ } resize.hasMoved = true; const scale = viewportRef.current?.scale ?? 1; + const delta: [number, number] = [ + (event.clientX - resize.startClientX) / scale, + (event.clientY - resize.startClientY) / scale, + ]; + if (event.shiftKey && !resize.ratioAxis) { + resize.ratioAxis = resolveProportionalResizeAxis( + [ + resize.startRect.max[0] - resize.startRect.min[0], + resize.startRect.max[1] - resize.startRect.min[1], + ], + delta, + ); + } const nextRect = resizePageRect( resize.startRect, resize.handle, - [ - (event.clientX - resize.startClientX) / scale, - (event.clientY - resize.startClientY) / scale, - ], + delta, event.shiftKey, + resize.ratioAxis ?? undefined, ); controller.updateNodeTransform( activeImageId, diff --git a/apps/ai-game-creator-shell/tests/proportionalResize.test.ts b/apps/ai-game-creator-shell/tests/proportionalResize.test.ts new file mode 100644 index 000000000..bf73c9dc0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/proportionalResize.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveProportionalResize, + resolveProportionalResizeAxis, +} from '../src/view/ui-editor/components/preview/proportionalResize'; + +describe('proportional resize', () => { + it('selects the axis with the greater relative size change', () => { + expect(resolveProportionalResizeAxis([100, 100], [30, -20])).toBe( + 'horizontal', + ); + expect(resolveProportionalResizeAxis([100, 100], [20, -30])).toBe( + 'vertical', + ); + }); + + it('uses the supplied locked axis instead of the larger candidate size', () => { + expect( + resolveProportionalResize({ + startSize: [100, 100], + size: [150, 90], + delta: [50, -10], + axis: 'horizontal', + }), + ).toEqual({ axis: 'horizontal', size: [150, 150] }); + }); + + it('uses the locked vertical axis for an opposite-sign diagonal drag', () => { + expect( + resolveProportionalResize({ + startSize: [100, 100], + size: [110, 50], + delta: [10, -50], + axis: 'vertical', + }), + ).toEqual({ axis: 'vertical', size: [50, 50] }); + }); +}); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dbbf715f3..12d0bdb8a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,9 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-08-19 UI Editor 预览等比角点缩放 + +UI Editor 预览中按住 Shift 拖动四个角点时,以本次进入等比缩放时相对原尺寸变化更大的轴作为主轴(完全相等时取水平轴),并在本次指针拖动内锁定。主轴按手柄方向和位移符号决定放大或缩小;另一轴只由初始宽高比推导,对角保持固定。不得再从两个轴各自推导的候选尺寸中取较大值,否则一轴放大、另一轴缩小时会覆盖较小轴的输入,导致控制点看似朝鼠标反方向跳动。锁定后,受比例驱动的从轴可以不跟随鼠标在该轴的位移,这是等比约束的明确结果。 + ## 2026-08-18 UI Editor Inspector 统一锁定态 UI Editor Inspector 的全局只读状态唯一来源是 `controller.editor.isLocked`。`InspectorSidebar` 在根层提供共享只读 Context,并在顶部只显示一次状态徽章:锁定时使用现有 slate 风格与“只读”锁图标,解锁时显示“可编辑”;整个 Inspector 内容区同步使用锁定表面。节点根的 Transform 局部只读继续仅约束该 Transform 区域,不能被解释为整个 Inspector 已锁定。