From c3a4dce3af2b36adc52e1d2e6c14d47393dea693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:53:29 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E8=A7=92=E7=82=B9=E5=81=8F=E7=A7=BB=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增四角选择与单组输入交互设计文档 记录 Transform offset 映射、方向键语义与验收边界 --- docs/README.md | 1 + ...计】UI编辑器变换角点偏移编辑器-2026-09-03.md | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md diff --git a/docs/README.md b/docs/README.md index 8492cccb9..5494771ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) - [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.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-19.md) diff --git a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md new file mode 100644 index 000000000..93d608421 --- /dev/null +++ b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md @@ -0,0 +1,45 @@ +# 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` 语义。切换角时读取同一组共享分量,修改后其他受影响角会随数据更新而同步。 + +## 交互 + +- 组件内部维护当前选中角,默认左上;节点或 `transform` 切换时重置为左上。 +- 四角按钮按左上、右上、左下、右下空间位置排布;当前角高亮并提供可访问名称。 +- 页面只渲染一组 X/Y 数字输入和一组上、下、左、右方向键,二者都只作用于当前选中角。 +- 方向键每次按 `1` 调整对应分量;按住支持连续触发,按住 `Shift` 时步长为 `10`。 +- 方向键映射:左/右调整选中角的 X, 上/下调整选中角的 Y。具体写入字段遵循上表,因此移动角点可能改变矩形尺寸。 +- 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。 +- 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。 + +## 非目标 + +- 不新增四角持久化字段,不修改 `Transform` schema 或后端接口。 +- 不提供四角独立状态,也不提供整体平移快捷键。 +- 不增加移动端专属布局或移动端交互。 + +## 验收 + +1. 四角选择能正确驱动唯一一组 X/Y 输入,并按数据映射表写回 `offset_min/offset_max`。 +2. 四方向键只修改当前选中角对应分量,步长、Shift 加速、长按行为可用。 +3. 输入提交、Escape 回滚、非法值、只读状态和现有 warning 行为不回归。 +4. `Transform` 类型、持久化数据和现有锚点编辑行为保持不变。 -- 2.52.0 From 7aaefbf6c1182f6b0bffb243c037eedd9fa75685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:02:11 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E9=87=8D=E5=81=9A=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E8=A7=92=E7=82=B9=E5=81=8F=E7=A7=BB=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增四角选择状态与单组 X/Y 偏移输入 让方向键只调整当前选中角并支持 Shift 加速与长按 --- .../Inspector/Transform/TransformEditor.tsx | 238 ++++++++++++++++-- 1 file changed, 220 insertions(+), 18 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index 85039f389..5c1ba6988 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -1,12 +1,16 @@ import { AlertTriangle, + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, ChevronDown, ChevronUp, Crosshair, Info, Move, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { pageRectFromSize, @@ -30,6 +34,7 @@ export type TransformEditorProps = { }; type Axis = 0 | 1; +type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; type AnchorPreset = { id: string; label: string; @@ -53,6 +58,21 @@ const ROW_MODE_LABELS: Record = { const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; +const CORNERS: readonly { + id: Corner; + label: string; + positionClass: string; +}[] = [ + { id: 'top-left', label: '左上角', positionClass: 'left-1 top-1' }, + { id: 'top-right', label: '右上角', positionClass: 'right-1 top-1' }, + { id: 'bottom-left', label: '左下角', positionClass: 'bottom-1 left-1' }, + { + id: 'bottom-right', + label: '右下角', + positionClass: 'bottom-1 right-1', + }, +]; + const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) => COLUMN_MODES.map((x) => ({ id: `${y}-${x}`, @@ -74,10 +94,6 @@ const FIELD_HINTS = { '锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。', anchor_max: '锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。', - offset_min: - '偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。', - offset_max: - '偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。', } as const; function cloneTransform(transform: Transform): Transform { @@ -166,6 +182,39 @@ function formatValue(value: number): string { 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({ label, hint, @@ -320,12 +369,80 @@ function ScalarInput({ ); } +function DirectionButton({ + ariaLabel, + disabled, + onAdjust, + children, +}: { + ariaLabel: string; + disabled: boolean; + onAdjust: (step: number) => void; + children: ReactNode; +}) { + const repeatTimeout = useRef | null>(null); + const repeatInterval = useRef | 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); + }; + + return ( + + ); +} + export function TransformEditor({ transform, parentSize, readOnly = false, onChange, }: TransformEditorProps) { + const [selectedCorner, setSelectedCorner] = useState('top-left'); + const transformRef = useRef(transform); + const selectedCornerRef = useRef(selectedCorner); + transformRef.current = transform; + selectedCornerRef.current = selectedCorner; const [presetOpen, setPresetOpen] = useState(false); const [customOpen, setCustomOpen] = useState( () => findPreset(transform).id === CUSTOM_PRESET.id, @@ -369,6 +486,24 @@ export function TransformEditor({ transform.anchor_min[1] > transform.anchor_max[1] || 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 (
) : null} -
+
+
+
+ 偏移角点 +
+ {CORNERS.map((corner) => { + const active = corner.id === selectedCorner; + return ( + + ); + })} +
+ updateVector('offset_min', axis, value)} - /> - updateVector('offset_max', axis, value)} + onCommit={(axis, value) => + onChange(updateCorner(transform, selectedCorner, axis, value)) + } /> + +
+ + + adjustSelectedCorner(1, -1, multiplier * 1) + } + > + + + + adjustSelectedCorner(0, -1, multiplier * 1) + } + > + +
+ {selectedCornerLabel} +
+ + adjustSelectedCorner(0, 1, multiplier * 1) + } + > + + + + adjustSelectedCorner(1, 1, multiplier * 1) + } + > + + +
-- 2.52.0 From 4883cb1193939962bb3359d12a40e5fdd9a619cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:02:38 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E8=A7=92=E7=82=B9=E7=BC=96=E8=BE=91=E5=99=A8=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖角点切换、共享 offset 映射和方向键调整 同步文档记录选中角点状态在编辑过程中的保持规则 --- .../tests/TransformEditor.test.tsx | 79 +++++++++++++++++++ ...计】UI编辑器变换角点偏移编辑器-2026-09-03.md | 2 +- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/tests/TransformEditor.test.tsx diff --git a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx new file mode 100644 index 000000000..e8b28f0dc --- /dev/null +++ b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { 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 renderEditor(onChange = vi.fn()) { + return { + onChange, + ...render(), + }; +} + +describe('TransformEditor corner offset controls', () => { + 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], + }); + }); +}); diff --git a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md index 93d608421..2d7728779 100644 --- a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md +++ b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md @@ -23,7 +23,7 @@ ## 交互 -- 组件内部维护当前选中角,默认左上;节点或 `transform` 切换时重置为左上。 +- 组件内部维护当前选中角,首次挂载时默认左上;切换角点由用户控制,编辑过程中不会因 offset 更新而跳回其他角。 - 四角按钮按左上、右上、左下、右下空间位置排布;当前角高亮并提供可访问名称。 - 页面只渲染一组 X/Y 数字输入和一组上、下、左、右方向键,二者都只作用于当前选中角。 - 方向键每次按 `1` 调整对应分量;按住支持连续触发,按住 `Shift` 时步长为 `10`。 -- 2.52.0 From bbe7ccfae473819ccba2770587598372b122266d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:51:12 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=B9=E5=90=91?= =?UTF-8?q?=E9=94=AE=E9=95=BF=E6=8C=89=E5=AE=9A=E6=97=B6=E5=99=A8=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组件卸载时清理重复触发的延时器和间隔器 避免 Inspector 关闭后继续向已卸载编辑器写入更新 --- .../Inspector/Transform/TransformEditor.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index 5c1ba6988..ed452ce6b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -407,6 +407,19 @@ function DirectionButton({ }, 350); }; + useEffect(() => { + return () => { + if (repeatTimeout.current) { + clearTimeout(repeatTimeout.current); + repeatTimeout.current = null; + } + if (repeatInterval.current) { + clearInterval(repeatInterval.current); + repeatInterval.current = null; + } + }; + }, []); + return ( - - {hint} - ); } @@ -425,7 +471,7 @@ function DirectionButton({ type="button" aria-label={ariaLabel} disabled={disabled} - className="grid size-10 place-items-center rounded-xl border border-emerald-200 bg-emerald-50 text-emerald-700 transition hover:border-emerald-300 hover:bg-emerald-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 disabled:cursor-default disabled:opacity-40" + 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) => { event.preventDefault(); startRepeating(event.shiftKey ? 10 : 1); @@ -673,91 +719,100 @@ export function TransformEditor({ ) : null} -
-
-
- 偏移角点 -
- {CORNERS.map((corner) => { - const active = corner.id === selectedCorner; - return ( - - ); - })} -
- - - onChange(updateCorner(transform, selectedCorner, axis, value)) - } +
+ -
- - - adjustSelectedCorner(1, -1, multiplier * 1) - } - > - - - - adjustSelectedCorner(0, -1, multiplier * 1) - } - > - -
- {selectedCornerLabel} +
+
+ {CORNERS.map((corner) => { + const active = corner.id === selectedCorner; + return ( + + ); + })} +
+ +
+
+ + + adjustSelectedCorner(1, -1, multiplier * 1) + } + > + + + + adjustSelectedCorner(0, -1, multiplier * 1) + } + > + +
+ +
+ + adjustSelectedCorner(0, 1, multiplier * 1) + } + > + + + + adjustSelectedCorner(1, 1, multiplier * 1) + } + > + + +
+ + + onChange(updateCorner(transform, selectedCorner, axis, value)) + } + />
- - adjustSelectedCorner(0, 1, multiplier * 1) - } - > - - - - adjustSelectedCorner(1, 1, multiplier * 1) - } - > - -
diff --git a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx index e8b28f0dc..62683bea3 100644 --- a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx +++ b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx @@ -25,20 +25,20 @@ describe('TransformEditor corner offset controls', () => { renderEditor(); expect( - screen.getByRole('spinbutton', { name: '左上角偏移 X' }), + screen.getByRole('spinbutton', { name: '位置微调 X' }), ).toHaveProperty('value', '10'); expect( - screen.getByRole('spinbutton', { name: '左上角偏移 Y' }), + 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' }), + screen.getByRole('spinbutton', { name: '位置微调 X' }), ).toHaveProperty('value', '30'); expect( - screen.getByRole('spinbutton', { name: '右下角偏移 Y' }), + screen.getByRole('spinbutton', { name: '位置微调 Y' }), ).toHaveProperty('value', '40'); }); @@ -46,9 +46,7 @@ describe('TransformEditor corner offset controls', () => { const { onChange } = renderEditor(); fireEvent.click(screen.getByRole('button', { name: '右上角' })); - const xInput = screen.getByRole('spinbutton', { - name: '右上角偏移 X', - }); + const xInput = screen.getByRole('spinbutton', { name: '位置微调 X' }); fireEvent.change(xInput, { target: { value: '55' } }); fireEvent.blur(xInput); diff --git a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md index 2d7728779..00fc90bb4 100644 --- a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md +++ b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md @@ -12,12 +12,12 @@ 四角是现有两个 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]` | +| 选中角 | 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` 语义。切换角时读取同一组共享分量,修改后其他受影响角会随数据更新而同步。 @@ -31,6 +31,20 @@ - 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。 - 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。 +## 视觉约束 + +- 四角选择器使用 icon-only 的 L 形角标,按左上、右上、左下、右下方向旋转;不在按钮内显示角点文字。 +- 角点示意图中心不显示说明文字,仅保留淡色矩形和角点高亮。 +- 输入区不显示“偏移”标签,只保留 X/Y 字段;说明信息按钮 `i` 放在“位置微调”标题旁,完整语义通过 `aria-label` 和 `title` 提供。 +- 说明只使用浏览器原生 `title` 提示,不再渲染自定义悬浮气泡。 +- 方向键保持十字布局,使用紧凑的中性 icon-only 按钮;橙色只用于选中态和焦点/悬停强调,避免大面积绿色填充。 +- 角点选择器和方向键的可点击尺寸不小于 28px。 +- 主控区采用两列布局:左侧为无分隔线的方形四角选择器,右侧上方为四方向键、下方为唯一一组 X/Y 输入。 +- 四角选择器不绘制连接线或虚线框,依靠方形容器、角标图标和选中态表达空间关系。 +- “位置微调”作为右侧控制区的短标题显示在两列之上;两列内容从同一条顶线开始对齐。 +- 当前角图标只保留在方向键十字中心,数字输入行不再重复显示角点指示器。 +- 左侧四角选择器保持较小的正方形,并在右侧方向键、纵向 X/Y 输入区的整体高度中垂直居中;X/Y 输入在右侧纵向排列,以避免窄面板中的横向拥挤。 + ## 非目标 - 不新增四角持久化字段,不修改 `Transform` schema 或后端接口。 -- 2.52.0 From 32c6e4ced8e5208b870f482ae9e9c90caef8e700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 14:27:48 +0800 Subject: [PATCH 10/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=B9=E5=90=91?= =?UTF-8?q?=E9=94=AE=E9=95=BF=E6=8C=89=E5=8F=96=E6=B6=88=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重置 pointercancel 和 pointerleave 的重复触发标记。 补充长按取消后点击仍可调整的回归测试。 --- .../Inspector/Transform/TransformEditor.tsx | 10 ++++++-- .../tests/TransformEditor.test.tsx | 23 +++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index 62e68347c..a1e8e5cd9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -477,8 +477,14 @@ function DirectionButton({ startRepeating(event.shiftKey ? 10 : 1); }} onPointerUp={stopRepeating} - onPointerCancel={stopRepeating} - onPointerLeave={stopRepeating} + onPointerCancel={() => { + repeated.current = false; + stopRepeating(); + }} + onPointerLeave={() => { + repeated.current = false; + stopRepeating(); + }} onClick={(event) => { if (!repeated.current) { onAdjust(event.shiftKey ? 10 : 1); diff --git a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx index 62683bea3..f672a9068 100644 --- a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx +++ b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +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'; @@ -21,6 +21,10 @@ function renderEditor(onChange = vi.fn()) { } describe('TransformEditor corner offset controls', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('uses one X/Y input pair for the selected corner', () => { renderEditor(); @@ -74,4 +78,19 @@ describe('TransformEditor corner offset controls', () => { 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: '左上角向右' }); + + fireEvent.pointerDown(button, { button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + fireEvent.pointerCancel(button); + fireEvent.click(button); + + expect(onChange).toHaveBeenCalledTimes(2); + }); }); -- 2.52.0 From 415240faa7bb8108ab5c877df33cd1b75bb4eeeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 14:31:04 +0800 Subject: [PATCH 11/13] =?UTF-8?q?=E9=99=90=E5=88=B6=E6=96=B9=E5=90=91?= =?UTF-8?q?=E9=94=AE=E9=95=BF=E6=8C=89=E4=BB=85=E5=93=8D=E5=BA=94=E4=B8=BB?= =?UTF-8?q?=E6=8C=87=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 忽略非主指针按钮的 pointerdown,避免右键触发连续调整。 补充非主键长按不产生调整的回归测试。 --- .../Inspector/Transform/TransformEditor.tsx | 3 +++ .../tests/TransformEditor.test.tsx | 21 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index a1e8e5cd9..20dc6f4bd 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -473,6 +473,9 @@ function DirectionButton({ 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); }} diff --git a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx index f672a9068..9145db48b 100644 --- a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx +++ b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx @@ -13,6 +13,12 @@ const transform: Transform = { 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, @@ -84,7 +90,7 @@ describe('TransformEditor corner offset controls', () => { const { onChange } = renderEditor(); const button = screen.getByRole('button', { name: '左上角向右' }); - fireEvent.pointerDown(button, { button: 0 }); + firePointerDown(button, 0); act(() => { vi.advanceTimersByTime(350); }); @@ -93,4 +99,17 @@ describe('TransformEditor corner offset controls', () => { 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(); + }); }); -- 2.52.0 From 83c12c3b0b25ce72715f23383cb43912673ab6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 14:31:42 +0800 Subject: [PATCH 12/13] =?UTF-8?q?=E6=B8=85=E7=90=86=E8=A7=92=E7=82=B9?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 CORNERS 中未被布局读取的 positionClass 死数据。 --- .../Inspector/Transform/TransformEditor.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index 20dc6f4bd..3749f9b48 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -61,16 +61,11 @@ const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; const CORNERS: readonly { id: Corner; label: string; - positionClass: string; }[] = [ - { id: 'top-left', label: '左上角', positionClass: 'left-1 top-1' }, - { id: 'top-right', label: '右上角', positionClass: 'right-1 top-1' }, - { id: 'bottom-left', label: '左下角', positionClass: 'bottom-1 left-1' }, - { - id: 'bottom-right', - label: '右下角', - positionClass: 'bottom-1 right-1', - }, + { id: 'top-left', label: '左上角' }, + { id: 'top-right', label: '右上角' }, + { id: 'bottom-left', label: '左下角' }, + { id: 'bottom-right', label: '右下角' }, ]; function CornerIcon({ -- 2.52.0 From 150628dcf94934b92ad92ed4d8c8834996b31cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 14:36:15 +0800 Subject: [PATCH 13/13] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E6=96=B9=E5=90=91=E9=94=AE=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录方向键主指针限制和长按取消状态清理语义。 --- docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md index 00fc90bb4..3ffc54549 100644 --- a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md +++ b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md @@ -27,6 +27,7 @@ - 四角按钮按左上、右上、左下、右下空间位置排布;当前角高亮并提供可访问名称。 - 页面只渲染一组 X/Y 数字输入和一组上、下、左、右方向键,二者都只作用于当前选中角。 - 方向键每次按 `1` 调整对应分量;按住支持连续触发,按住 `Shift` 时步长为 `10`。 +- 方向键长按只响应主指针;指针取消或离开时清理长按抑制状态,正常释放仍抑制尾随点击。 - 方向键映射:左/右调整选中角的 X, 上/下调整选中角的 Y。具体写入字段遵循上表,因此移动角点可能改变矩形尺寸。 - 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。 - 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。 -- 2.52.0