修复UI编辑器等比角点缩放

抽取等比缩放主轴与尺寸推导算法

锁定单次拖拽的主导轴并保持对角锚点

补充比例缩放回归测试与交互规则文档
This commit is contained in:
2026-08-19 13:20:45 +08:00
parent 2cf3183e78
commit a5e15bf48a
5 changed files with 124 additions and 23 deletions
@@ -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;
@@ -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],
};
}
@@ -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,
@@ -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] });
});
});
@@ -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 已锁定。