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) + } + > + + +