From eecc4c7304a8232f60be3f79d971075912876cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 12 Aug 2026 16:26:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20UI=20Transform=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Unity 风格锚点预设与自定义锚点编辑 始终展示并编辑 Offset Min 与 Offset Max 支持按父级尺寸保持解析矩形不变 --- .../transform-utils/TransformEditor.tsx | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/TransformEditor.tsx diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/TransformEditor.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/TransformEditor.tsx new file mode 100644 index 000000000..2ec720f05 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/TransformEditor.tsx @@ -0,0 +1,531 @@ +import { + AlertTriangle, + ChevronDown, + Crosshair, + LockKeyhole, + Maximize2, + Move, + SlidersHorizontal, + Unlock, +} from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { Transform } from '../types'; + +export type TransformEditorParentSize = { + width: number; + height: number; +}; + +export type TransformEditorProps = { + transform: Transform; + parentSize?: TransformEditorParentSize; + readOnly?: boolean; + onChange: (nextTransform: Transform) => void; +}; + +type Axis = 0 | 1; +type AnchorMode = 'left' | 'center' | 'right' | 'stretch'; +type AnchorPreset = { + id: string; + label: string; + x: AnchorMode; + y: AnchorMode; +}; + +const AXIS_LABELS = ['X', 'Y'] as const; +const AXIS_MODE_LABELS: Record = { + left: 'Left', + center: 'Center', + right: 'Right', + stretch: 'Stretch', +}; +const ROW_MODE_LABELS: Record = { + left: 'Top', + center: 'Middle', + right: 'Bottom', + stretch: 'Stretch', +}; +const COLUMN_MODES: AnchorMode[] = ['left', 'center', 'right', 'stretch']; +const ROW_MODES: AnchorMode[] = ['left', 'center', 'right', 'stretch']; + +const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) => + COLUMN_MODES.map((x) => ({ + id: `${y}-${x}`, + label: `${ROW_MODE_LABELS[y]} / ${AXIS_MODE_LABELS[x]}`, + x, + y, + })), +); + +const CUSTOM_PRESET: AnchorPreset = { + id: 'custom', + label: 'Custom', + x: 'left', + y: 'left', +}; + +function cloneTransform(transform: Transform): Transform { + return { + anchor_min: [...transform.anchor_min], + anchor_max: [...transform.anchor_max], + offset_min: [...transform.offset_min], + offset_max: [...transform.offset_max], + }; +} + +function modeAnchors(mode: AnchorMode): [number, number] { + if (mode === 'stretch') { + return [0, 1]; + } + const value = mode === 'left' ? 0 : mode === 'center' ? 0.5 : 1; + return [value, value]; +} + +function findPreset(transform: Transform): AnchorPreset { + const preset = PRESETS.find((candidate) => { + const [xMin, xMax] = modeAnchors(candidate.x); + const [yMin, yMax] = modeAnchors(candidate.y); + return ( + transform.anchor_min[0] === xMin && + transform.anchor_max[0] === xMax && + transform.anchor_min[1] === yMin && + transform.anchor_max[1] === yMax + ); + }); + return preset ?? CUSTOM_PRESET; +} + +function hasUsableParentSize( + parentSize: TransformEditorParentSize | undefined, +): parentSize is TransformEditorParentSize { + return Boolean( + parentSize && + Number.isFinite(parentSize.width) && + Number.isFinite(parentSize.height) && + parentSize.width > 0 && + parentSize.height > 0, + ); +} + +function applyPreset( + transform: Transform, + preset: AnchorPreset, + parentSize?: TransformEditorParentSize, +): Transform { + const next = cloneTransform(transform); + const [nextAnchorMinX, nextAnchorMaxX] = modeAnchors(preset.x); + const [nextAnchorMinY, nextAnchorMaxY] = modeAnchors(preset.y); + + if (hasUsableParentSize(parentSize)) { + const currentMinX = + parentSize.width * transform.anchor_min[0] + transform.offset_min[0]; + const currentMinY = + parentSize.height * transform.anchor_min[1] + transform.offset_min[1]; + const currentMaxX = + parentSize.width * transform.anchor_max[0] + transform.offset_max[0]; + const currentMaxY = + parentSize.height * transform.anchor_max[1] + transform.offset_max[1]; + + next.offset_min = [ + currentMinX - parentSize.width * nextAnchorMinX, + currentMinY - parentSize.height * nextAnchorMinY, + ]; + next.offset_max = [ + currentMaxX - parentSize.width * nextAnchorMaxX, + currentMaxY - parentSize.height * nextAnchorMaxY, + ]; + } + + next.anchor_min = [nextAnchorMinX, nextAnchorMinY]; + next.anchor_max = [nextAnchorMaxX, nextAnchorMaxY]; + return next; +} + +function resolvedGeometry( + transform: Transform, + parentSize: TransformEditorParentSize | undefined, +) { + if (!hasUsableParentSize(parentSize)) { + return null; + } + const left = + parentSize.width * transform.anchor_min[0] + transform.offset_min[0]; + const top = + parentSize.height * transform.anchor_min[1] + transform.offset_min[1]; + const right = + parentSize.width * transform.anchor_max[0] + transform.offset_max[0]; + const bottom = + parentSize.height * transform.anchor_max[1] + transform.offset_max[1]; + return { + left, + top, + width: right - left, + height: bottom - top, + invalid: right < left || bottom < top, + }; +} + +function formatValue(value: number): string { + if (Object.is(value, -0)) { + return '0'; + } + return String(Number(value.toFixed(4))); +} + +function VectorInputRow({ + label, + values, + step, + readOnly, + onCommit, +}: { + label: string; + values: readonly [number, number]; + step: number; + readOnly: boolean; + onCommit: (axis: Axis, value: number) => void; +}) { + return ( +
+ + {label} + + {AXIS_LABELS.map((axisLabel, axis) => ( + onCommit(axis as Axis, value)} + /> + ))} +
+ ); +} + +function ScalarInput({ + ariaLabel, + value, + step, + readOnly, + onCommit, +}: { + ariaLabel: string; + value: number; + step: number; + readOnly: boolean; + onCommit: (value: number) => void; +}) { + const [draft, setDraft] = useState(formatValue(value)); + + useEffect(() => { + setDraft(formatValue(value)); + }, [value]); + + const commit = () => { + const next = Number(draft); + if (!draft.trim() || !Number.isFinite(next)) { + setDraft(formatValue(value)); + return; + } + onCommit(next); + setDraft(formatValue(next)); + }; + + return ( + + ); +} + +export function TransformEditor({ + transform, + parentSize, + readOnly = false, + onChange, +}: TransformEditorProps) { + const [presetOpen, setPresetOpen] = useState(false); + const [customOpen, setCustomOpen] = useState(false); + const presetRef = useRef(null); + const selectedPreset = useMemo(() => findPreset(transform), [transform]); + const geometry = useMemo( + () => resolvedGeometry(transform, parentSize), + [parentSize, transform], + ); + + useEffect(() => { + if (!presetOpen) { + return; + } + const onPointerDown = (event: PointerEvent) => { + if (!presetRef.current?.contains(event.target as Node)) { + setPresetOpen(false); + } + }; + document.addEventListener('pointerdown', onPointerDown); + return () => document.removeEventListener('pointerdown', onPointerDown); + }, [presetOpen]); + + const updateVector = ( + field: 'anchor_min' | 'anchor_max' | 'offset_min' | 'offset_max', + axis: Axis, + value: number, + ) => { + if (readOnly) { + return; + } + const next = cloneTransform(transform); + next[field][axis] = value; + onChange(next); + }; + + const warning = + transform.anchor_min[0] > transform.anchor_max[0] || + transform.anchor_min[1] > transform.anchor_max[1] || + transform.anchor_min.some((value) => value < 0 || value > 1) || + transform.anchor_max.some((value) => value < 0 || value > 1) || + geometry?.invalid; + + return ( +
+
+
+ + +
+

Rect Transform

+

+ Anchors & offsets +

+
+
+ {readOnly ? ( + + ) : ( + + )} +
+ +
+
+ + + {presetOpen ? ( +
+
+ + Anchor + + + 4 × 4 + +
+
+ + {COLUMN_MODES.map((column) => ( + + {AXIS_MODE_LABELS[column]} + + ))} + {ROW_MODES.flatMap((row) => [ + + {ROW_MODE_LABELS[row]} + , + ...COLUMN_MODES.map((column) => { + const preset = PRESETS.find( + (candidate) => candidate.id === `${row}-${column}`, + ) as AnchorPreset; + const active = selectedPreset.id === preset.id; + return ( + + ); + }), + ])} +
+ +
+ ) : null} +
+ + {warning ? ( +
+
+ ) : null} + +
+
+ + {AXIS_LABELS.map((axis) => ( + + {axis} + + ))} +
+ updateVector('offset_min', axis, value)} + /> + updateVector('offset_max', axis, value)} + /> +
+ + {customOpen ? ( +
+
+
+ + updateVector('anchor_min', axis, value) + } + /> + + updateVector('anchor_max', axis, value) + } + /> +
+ ) : null} +
+
+ ); +} + +export default TransformEditor;