Merge branch 'master' into fix/ui-editor-enexpected-select
Project CI / Repository checks (pull_request) Successful in 2m45s
Project CI / Frontend tests (pull_request) Successful in 3m3s
Project CI / Backend tests (pull_request) Successful in 7m21s
Project CI / Native shell tests (pull_request) Successful in 19m30s

This commit is contained in:
2026-09-03 19:46:28 +08:00
4 changed files with 493 additions and 38 deletions
@@ -1,12 +1,16 @@
import { import {
AlertTriangle, AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Crosshair, Crosshair,
Info, Info,
Move, Move,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { import {
pageRectFromSize, pageRectFromSize,
@@ -30,6 +34,7 @@ export type TransformEditorProps = {
}; };
type Axis = 0 | 1; type Axis = 0 | 1;
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
type AnchorPreset = { type AnchorPreset = {
id: string; id: string;
label: string; label: string;
@@ -53,6 +58,45 @@ const ROW_MODE_LABELS: Record<AnchorMode, string> = {
const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
const CORNERS: readonly {
id: Corner;
label: string;
}[] = [
{ id: 'top-left', label: '左上角' },
{ id: 'top-right', label: '右上角' },
{ id: 'bottom-left', label: '左下角' },
{ id: 'bottom-right', label: '右下角' },
];
function CornerIcon({
corner,
active = false,
}: {
corner: Corner;
active?: boolean;
}) {
const paths: Record<Corner, string> = {
'top-left': 'M5 11V5h6',
'top-right': 'M13 11V5H7',
'bottom-left': 'M5 9v6h6',
'bottom-right': 'M13 9v6H7',
};
return (
<svg
aria-hidden="true"
viewBox="0 0 18 20"
className={`size-5 ${active ? 'text-(--platform-accent)' : 'text-(--platform-text-soft)'}`}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={active ? 2.2 : 1.8}
>
<path d={paths[corner]} />
</svg>
);
}
const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) => const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) =>
COLUMN_MODES.map((x) => ({ COLUMN_MODES.map((x) => ({
id: `${y}-${x}`, id: `${y}-${x}`,
@@ -74,10 +118,6 @@ const FIELD_HINTS = {
'锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。', '锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。',
anchor_max: anchor_max:
'锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。', '锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。',
offset_min:
'偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。',
offset_max:
'偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。',
} as const; } as const;
function cloneTransform(transform: Transform): Transform { function cloneTransform(transform: Transform): Transform {
@@ -166,9 +206,44 @@ function formatValue(value: number): string {
return String(Number(value.toFixed(2))); return String(Number(value.toFixed(2)));
} }
function cornerFields(corner: Corner): {
x: 'offset_min' | 'offset_max';
y: 'offset_min' | 'offset_max';
} {
switch (corner) {
case 'top-left':
return { x: 'offset_min', y: 'offset_min' };
case 'top-right':
return { x: 'offset_max', y: 'offset_min' };
case 'bottom-left':
return { x: 'offset_min', y: 'offset_max' };
case 'bottom-right':
return { x: 'offset_max', y: 'offset_max' };
}
}
function cornerValues(transform: Transform, corner: Corner): [number, number] {
const fields = cornerFields(corner);
return [transform[fields.x][0], transform[fields.y][1]];
}
function updateCorner(
transform: Transform,
corner: Corner,
axis: Axis,
value: number,
): Transform {
const fields = cornerFields(corner);
const next = cloneTransform(transform);
next[axis === 0 ? fields.x : fields.y][axis] = value;
return next;
}
function VectorInputRow({ function VectorInputRow({
label, label,
hint, hint,
hideLabel = false,
stacked = false,
values, values,
step, step,
readOnly, readOnly,
@@ -176,24 +251,45 @@ function VectorInputRow({
}: { }: {
label: string; label: string;
hint: string; hint: string;
hideLabel?: boolean;
stacked?: boolean;
values: readonly [number, number]; values: readonly [number, number];
step: number; step: number;
readOnly: boolean; readOnly: boolean;
onCommit: (axis: Axis, value: number) => void; onCommit: (axis: Axis, value: number) => void;
}) { }) {
const fields = AXIS_LABELS.map((axisLabel, axis) => (
<ScalarInput
key={axisLabel}
ariaLabel={`${label} ${axisLabel}`}
value={values[axis as Axis]}
step={step}
readOnly={readOnly}
onCommit={(value) => onCommit(axis as Axis, value)}
/>
));
if (stacked) {
return (
<div className="grid min-w-0 gap-2">
{hideLabel ? (
<span className="sr-only">{label}</span>
) : (
<FieldLabel label={label} hint={hint} />
)}
<div className="grid min-w-0 gap-2">{fields}</div>
</div>
);
}
return ( return (
<div className="grid min-w-0 grid-cols-[minmax(0,5rem)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2"> <div className="grid min-w-0 grid-cols-[minmax(0,5rem)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2">
<FieldLabel label={label} hint={hint} /> {hideLabel ? (
{AXIS_LABELS.map((axisLabel, axis) => ( <span className="sr-only">{label}</span>
<ScalarInput ) : (
key={axisLabel} <FieldLabel label={label} hint={hint} />
ariaLabel={`${label} ${axisLabel}`} )}
value={values[axis as Axis]} {fields}
step={step}
readOnly={readOnly}
onCommit={(value) => onCommit(axis as Axis, value)}
/>
))}
</div> </div>
); );
} }
@@ -201,7 +297,7 @@ function VectorInputRow({
function FieldLabel({ label, hint }: { label: string; hint: string }) { function FieldLabel({ label, hint }: { label: string; hint: string }) {
return ( return (
<span <span
className="group/field relative inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)" className="inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)"
title={hint} title={hint}
> >
{label} {label}
@@ -213,12 +309,6 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) {
> >
<Info size={11} aria-hidden="true" /> <Info size={11} aria-hidden="true" />
</button> </button>
<span
role="tooltip"
className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 z-40 hidden w-64 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-2.5 py-2 text-[10px] font-normal leading-relaxed text-(--platform-neutral-text) shadow-lg group-hover/field:block group-focus-within/field:block"
>
{hint}
</span>
</span> </span>
); );
} }
@@ -320,12 +410,105 @@ function ScalarInput({
); );
} }
function DirectionButton({
ariaLabel,
disabled,
onAdjust,
children,
}: {
ariaLabel: string;
disabled: boolean;
onAdjust: (step: number) => void;
children: ReactNode;
}) {
const repeatTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const repeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const repeated = useRef(false);
const stopRepeating = () => {
if (repeatTimeout.current) {
clearTimeout(repeatTimeout.current);
repeatTimeout.current = null;
}
if (repeatInterval.current) {
clearInterval(repeatInterval.current);
repeatInterval.current = null;
}
};
const startRepeating = (multiplier: number) => {
if (disabled) {
return;
}
repeated.current = false;
repeatTimeout.current = setTimeout(() => {
repeated.current = true;
onAdjust(multiplier);
repeatInterval.current = setInterval(() => onAdjust(multiplier), 70);
}, 350);
};
useEffect(() => {
return () => {
if (repeatTimeout.current) {
clearTimeout(repeatTimeout.current);
repeatTimeout.current = null;
}
if (repeatInterval.current) {
clearInterval(repeatInterval.current);
repeatInterval.current = null;
}
};
}, []);
return (
<button
type="button"
aria-label={ariaLabel}
disabled={disabled}
className="grid size-8 place-items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 text-(--platform-text-soft) transition hover:border-orange-300 hover:bg-orange-50 hover:text-orange-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 disabled:cursor-default disabled:opacity-40"
onPointerDown={(event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
startRepeating(event.shiftKey ? 10 : 1);
}}
onPointerUp={stopRepeating}
onPointerCancel={() => {
repeated.current = false;
stopRepeating();
}}
onPointerLeave={() => {
repeated.current = false;
stopRepeating();
}}
onClick={(event) => {
if (!repeated.current) {
onAdjust(event.shiftKey ? 10 : 1);
}
repeated.current = false;
stopRepeating();
}}
>
{children}
</button>
);
}
export function TransformEditor({ export function TransformEditor({
transform, transform,
parentSize, parentSize,
readOnly = false, readOnly = false,
onChange, onChange,
}: TransformEditorProps) { }: TransformEditorProps) {
const [selectedCorner, setSelectedCorner] = useState<Corner>('top-left');
const transformRef = useRef(transform);
const selectedCornerRef = useRef(selectedCorner);
useEffect(() => {
transformRef.current = transform;
selectedCornerRef.current = selectedCorner;
}, [transform, selectedCorner]);
const [presetOpen, setPresetOpen] = useState(false); const [presetOpen, setPresetOpen] = useState(false);
const [customOpen, setCustomOpen] = useState( const [customOpen, setCustomOpen] = useState(
() => findPreset(transform).id === CUSTOM_PRESET.id, () => findPreset(transform).id === CUSTOM_PRESET.id,
@@ -369,6 +552,24 @@ export function TransformEditor({
transform.anchor_min[1] > transform.anchor_max[1] || transform.anchor_min[1] > transform.anchor_max[1] ||
geometry?.invalid; geometry?.invalid;
const selectedCornerLabel =
CORNERS.find((corner) => corner.id === selectedCorner)?.label ?? '左上角';
const selectedCornerValues = cornerValues(transform, selectedCorner);
const adjustSelectedCorner = (
axis: Axis,
direction: -1 | 1,
multiplier = 1,
) => {
if (readOnly) {
return;
}
const currentTransform = transformRef.current;
const currentCorner = selectedCornerRef.current;
const current = cornerValues(currentTransform, currentCorner)[axis];
const next = Number((current + direction * multiplier).toFixed(4));
onChange(updateCorner(currentTransform, currentCorner, axis, next));
};
return ( return (
<section <section
aria-readonly={readOnly} aria-readonly={readOnly}
@@ -523,22 +724,100 @@ export function TransformEditor({
) : null} ) : null}
<div className="grid gap-2"> <div className="grid gap-2">
<VectorInputRow <FieldLabel
label="偏移最小值" label="位置微调"
hint={FIELD_HINTS.offset_min} hint="调整当前选中角的原始 offset 值。"
values={transform.offset_min}
step={1}
readOnly={readOnly}
onCommit={(axis, value) => updateVector('offset_min', axis, value)}
/>
<VectorInputRow
label="偏移最大值"
hint={FIELD_HINTS.offset_max}
values={transform.offset_max}
step={1}
readOnly={readOnly}
onCommit={(axis, value) => updateVector('offset_max', axis, value)}
/> />
<div className="grid grid-cols-[minmax(7rem,8rem)_minmax(0,1fr)] items-center gap-4">
<div className="grid aspect-square w-full grid-cols-2 grid-rows-2 gap-1.5 justify-self-center rounded-2xl border border-(--platform-subpanel-border) bg-white/45 p-1.5">
{CORNERS.map((corner) => {
const active = corner.id === selectedCorner;
return (
<button
key={corner.id}
type="button"
aria-label={corner.label}
aria-pressed={active}
disabled={readOnly}
title={corner.label}
className={`grid min-h-10 min-w-10 place-items-center rounded-xl border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-300 ${active ? 'border-(--platform-accent) bg-(--platform-warm-bg)' : 'border-transparent hover:border-(--platform-accent) hover:bg-(--platform-warm-bg)'}`}
onClick={() => {
selectedCornerRef.current = corner.id;
setSelectedCorner(corner.id);
}}
>
<CornerIcon corner={corner.id} active={active} />
</button>
);
})}
</div>
<div className="grid min-w-0 gap-3">
<div
role="group"
className="mx-auto grid grid-cols-3 grid-rows-3 gap-1.5"
aria-label="角点方向键"
>
<span />
<DirectionButton
ariaLabel={`${selectedCornerLabel}向上`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(1, -1, multiplier * 1)
}
>
<ArrowUp size={17} aria-hidden="true" />
</DirectionButton>
<span />
<DirectionButton
ariaLabel={`${selectedCornerLabel}向左`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(0, -1, multiplier * 1)
}
>
<ArrowLeft size={17} aria-hidden="true" />
</DirectionButton>
<div className="grid size-8 place-items-center rounded-lg bg-slate-100 text-(--platform-text-soft)">
<CornerIcon corner={selectedCorner} />
</div>
<DirectionButton
ariaLabel={`${selectedCornerLabel}向右`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(0, 1, multiplier * 1)
}
>
<ArrowRight size={17} aria-hidden="true" />
</DirectionButton>
<span />
<DirectionButton
ariaLabel={`${selectedCornerLabel}向下`}
disabled={readOnly}
onAdjust={(multiplier) =>
adjustSelectedCorner(1, 1, multiplier * 1)
}
>
<ArrowDown size={17} aria-hidden="true" />
</DirectionButton>
<span />
</div>
<VectorInputRow
label="位置微调"
hideLabel
hint="当前选中角的原始 offset 值。切换角点后,X/Y 会映射到对应的 offset_min 或 offset_max 分量。"
values={selectedCornerValues}
step={1}
readOnly={readOnly}
stacked
onCommit={(axis, value) =>
onChange(updateCorner(transform, selectedCorner, axis, value))
}
/>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Transform } from '../src/features/ui-editor/types/Transform';
import { TransformEditor } from '../src/view/ui-editor/components/Inspector/Transform/TransformEditor';
const transform: Transform = {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [10, 20],
offset_max: [30, 40],
};
function firePointerDown(element: HTMLElement, button: number) {
const event = new Event('pointerdown', { bubbles: true });
Object.defineProperty(event, 'button', { value: button });
fireEvent(element, event);
}
function renderEditor(onChange = vi.fn()) {
return {
onChange,
...render(<TransformEditor transform={transform} onChange={onChange} />),
};
}
describe('TransformEditor corner offset controls', () => {
afterEach(() => {
vi.useRealTimers();
});
it('uses one X/Y input pair for the selected corner', () => {
renderEditor();
expect(
screen.getByRole('spinbutton', { name: '位置微调 X' }),
).toHaveProperty('value', '10');
expect(
screen.getByRole('spinbutton', { name: '位置微调 Y' }),
).toHaveProperty('value', '20');
expect(screen.getAllByRole('spinbutton')).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: '右下角' }));
expect(
screen.getByRole('spinbutton', { name: '位置微调 X' }),
).toHaveProperty('value', '30');
expect(
screen.getByRole('spinbutton', { name: '位置微调 Y' }),
).toHaveProperty('value', '40');
});
it('writes the selected corner input back to the matching offset component', () => {
const { onChange } = renderEditor();
fireEvent.click(screen.getByRole('button', { name: '右上角' }));
const xInput = screen.getByRole('spinbutton', { name: '位置微调 X' });
fireEvent.change(xInput, { target: { value: '55' } });
fireEvent.blur(xInput);
expect(onChange).toHaveBeenLastCalledWith({
...transform,
offset_max: [55, 40],
});
});
it('moves only the selected corner with direction keys', () => {
const { onChange } = renderEditor();
fireEvent.click(screen.getByRole('button', { name: '右下角' }));
fireEvent.click(screen.getByRole('button', { name: '右下角向左' }));
expect(onChange).toHaveBeenLastCalledWith({
...transform,
offset_max: [29, 40],
});
fireEvent.click(screen.getByRole('button', { name: '右下角向上' }), {
shiftKey: true,
});
expect(onChange).toHaveBeenLastCalledWith({
...transform,
offset_max: [30, 30],
});
});
it('allows a click adjustment after a cancelled long press', () => {
vi.useFakeTimers();
const { onChange } = renderEditor();
const button = screen.getByRole('button', { name: '左上角向右' });
firePointerDown(button, 0);
act(() => {
vi.advanceTimersByTime(350);
});
fireEvent.pointerCancel(button);
fireEvent.click(button);
expect(onChange).toHaveBeenCalledTimes(2);
});
it('does not start repeating for non-primary pointer buttons', () => {
vi.useFakeTimers();
const { onChange } = renderEditor();
const button = screen.getByRole('button', { name: '左上角向右' });
firePointerDown(button, 2);
act(() => {
vi.advanceTimersByTime(350);
});
expect(onChange).not.toHaveBeenCalled();
});
});
+1
View File
@@ -30,6 +30,7 @@
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md)
- [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md) - [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)
- [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md)
- [UI 编辑器变换角点偏移编辑器](./technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md)
- [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md) - [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md)
- [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md) - [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md)
- [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md) - [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md)
@@ -0,0 +1,60 @@
# UI 编辑器变换角点偏移编辑器
更新时间:`2026-09-03`
## 目标
将 UI 编辑器 Inspector 中的 offset 编辑从两行 `offset_min/offset_max` 改为四角选择式编辑:界面展示左上、右上、左下、右下四个角,用户先选择一个角,再使用唯一的一组 X/Y 数字输入和四个方向键调整该角。
本次只改变编辑器表现和交互,不改变 `Transform` 数据结构、持久化格式或后端契约。
## 数据映射
四角是现有两个 offset 向量的投影,不是四组独立数据:
| 选中角 | X 输入写入 | Y 输入写入 |
| ------ | --------------- | --------------- |
| 左上 | `offset_min[0]` | `offset_min[1]` |
| 右上 | `offset_max[0]` | `offset_min[1]` |
| 左下 | `offset_min[0]` | `offset_max[1]` |
| 右下 | `offset_max[0]` | `offset_max[1]` |
输入框始终显示对应 `Transform` 的原始 offset 值,保留现有正负号;不转换成 CSS `right`/`bottom` 语义。切换角时读取同一组共享分量,修改后其他受影响角会随数据更新而同步。
## 交互
- 组件内部维护当前选中角,首次挂载时默认左上;切换角点由用户控制,编辑过程中不会因 offset 更新而跳回其他角。
- 四角按钮按左上、右上、左下、右下空间位置排布;当前角高亮并提供可访问名称。
- 页面只渲染一组 X/Y 数字输入和一组上、下、左、右方向键,二者都只作用于当前选中角。
- 方向键每次按 `1` 调整对应分量;按住支持连续触发,按住 `Shift` 时步长为 `10`
- 方向键长按只响应主指针;指针取消或离开时清理长按抑制状态,正常释放仍抑制尾随点击。
- 方向键映射:左/右调整选中角的 X, 上/下调整选中角的 Y。具体写入字段遵循上表,因此移动角点可能改变矩形尺寸。
- 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。
- 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。
## 视觉约束
- 四角选择器使用 icon-only 的 L 形角标,按左上、右上、左下、右下方向旋转;不在按钮内显示角点文字。
- 角点示意图中心不显示说明文字,仅保留淡色矩形和角点高亮。
- 输入区不显示“偏移”标签,只保留 X/Y 字段;说明信息按钮 `i` 放在“位置微调”标题旁,完整语义通过 `aria-label``title` 提供。
- 说明只使用浏览器原生 `title` 提示,不再渲染自定义悬浮气泡。
- 方向键保持十字布局,使用紧凑的中性 icon-only 按钮;橙色只用于选中态和焦点/悬停强调,避免大面积绿色填充。
- 角点选择器和方向键的可点击尺寸不小于 28px。
- 主控区采用两列布局:左侧为无分隔线的方形四角选择器,右侧上方为四方向键、下方为唯一一组 X/Y 输入。
- 四角选择器不绘制连接线或虚线框,依靠方形容器、角标图标和选中态表达空间关系。
- “位置微调”作为右侧控制区的短标题显示在两列之上;两列内容从同一条顶线开始对齐。
- 当前角图标只保留在方向键十字中心,数字输入行不再重复显示角点指示器。
- 左侧四角选择器保持较小的正方形,并在右侧方向键、纵向 X/Y 输入区的整体高度中垂直居中;X/Y 输入在右侧纵向排列,以避免窄面板中的横向拥挤。
## 非目标
- 不新增四角持久化字段,不修改 `Transform` schema 或后端接口。
- 不提供四角独立状态,也不提供整体平移快捷键。
- 不增加移动端专属布局或移动端交互。
## 验收
1. 四角选择能正确驱动唯一一组 X/Y 输入,并按数据映射表写回 `offset_min/offset_max`
2. 四方向键只修改当前选中角对应分量,步长、Shift 加速、长按行为可用。
3. 输入提交、Escape 回滚、非法值、只读状态和现有 warning 行为不回归。
4. `Transform` 类型、持久化数据和现有锚点编辑行为保持不变。