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..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 @@ -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,45 @@ 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; +}[] = [ + { 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 = { + 'top-left': 'M5 11V5h6', + 'top-right': 'M13 11V5H7', + 'bottom-left': 'M5 9v6h6', + 'bottom-right': 'M13 9v6H7', + }; + return ( + + ); +} + const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) => COLUMN_MODES.map((x) => ({ id: `${y}-${x}`, @@ -74,10 +118,6 @@ const FIELD_HINTS = { '锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。', anchor_max: '锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。', - offset_min: - '偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。', - offset_max: - '偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。', } as const; function cloneTransform(transform: Transform): Transform { @@ -166,9 +206,44 @@ 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, + hideLabel = false, + stacked = false, values, step, readOnly, @@ -176,24 +251,45 @@ function VectorInputRow({ }: { label: string; hint: string; + hideLabel?: boolean; + stacked?: boolean; values: readonly [number, number]; step: number; readOnly: boolean; onCommit: (axis: Axis, value: number) => void; }) { + const fields = AXIS_LABELS.map((axisLabel, axis) => ( + onCommit(axis as Axis, value)} + /> + )); + + if (stacked) { + return ( +
+ {hideLabel ? ( + {label} + ) : ( + + )} +
{fields}
+
+ ); + } + return (
- - {AXIS_LABELS.map((axisLabel, axis) => ( - onCommit(axis as Axis, value)} - /> - ))} + {hideLabel ? ( + {label} + ) : ( + + )} + {fields}
); } @@ -201,7 +297,7 @@ function VectorInputRow({ function FieldLabel({ label, hint }: { label: string; hint: string }) { return ( {label} @@ -213,12 +309,6 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) { > ); } @@ -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 | 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); + }; + + useEffect(() => { + return () => { + if (repeatTimeout.current) { + clearTimeout(repeatTimeout.current); + repeatTimeout.current = null; + } + if (repeatInterval.current) { + clearInterval(repeatInterval.current); + repeatInterval.current = null; + } + }; + }, []); + + return ( + + ); +} + export function TransformEditor({ transform, parentSize, readOnly = false, onChange, }: TransformEditorProps) { + const [selectedCorner, setSelectedCorner] = useState('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 [customOpen, setCustomOpen] = useState( () => findPreset(transform).id === CUSTOM_PRESET.id, @@ -369,6 +552,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 (
- updateVector('offset_min', axis, value)} - /> - updateVector('offset_max', axis, value)} + + +
+
+ {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)) + } + /> +
+
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..9145db48b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx @@ -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(), + }; +} + +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(); + }); +}); diff --git a/docs/README.md b/docs/README.md index 985ec1c12..d44e466ad 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) - [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.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..3ffc54549 --- /dev/null +++ b/docs/technical/【设计】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` 类型、持久化数据和现有锚点编辑行为保持不变。