From de966ead193b8ffe8f00b994d838fee6d1a16c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 14 Aug 2026 20:27:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BB=84=E4=BB=B6=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 `ComponentPanel`,支持组件的新增、删除、移动和更新操作。 - 添加组件字段编辑表单 (`ComponentField`) 及组件类型编辑接口。 - 引入支持图片和文本组件的渲染逻辑 (`ImagePanel`, `TextPanel`)。 - 添加组件到 CSS 转换的工具函数 (`componentToCss.ts`),支持图片和文本样式生成。 - 优化 Inspector,支持多层级组件的编辑和操作。 --- apps/ai-game-creator-shell/package-lock.json | 27 +- apps/ai-game-creator-shell/package.json | 1 + .../ui-editor/utils/componentToCss.ts | 279 +++++++++++ .../Inspector/Components/ComponentField.tsx | 95 ++++ .../Inspector/Components/ComponentPanel.tsx | 309 ++++++++++++ .../Inspector/Components/ImagePanel.tsx | 442 ++++++++++++++++++ .../Components/ImagePanelDefaults.ts | 8 + .../Inspector/Components/TextPanel.tsx | 259 ++++++++++ .../Inspector/Components/TextPanelDefaults.ts | 15 + .../Components/componentEditorTypes.ts | 39 ++ .../components/preview/UiTreeRenderer.tsx | 170 +++++++ .../preview/components/ComponentView.tsx | 27 ++ .../preview/components/ImageComponentView.tsx | 45 ++ .../preview/components/MissingComponent.tsx | 11 + .../preview/components/TextComponentView.tsx | 30 ++ .../components/preview/components/types.ts | 8 + .../preview/nodeTransformGeometry.ts | 133 ++++++ .../preview/useNodeTransformInteraction.ts | 227 +++++++++ 18 files changed, 2117 insertions(+), 8 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentField.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanelDefaults.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanelDefaults.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/ComponentView.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/ImageComponentView.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/MissingComponent.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/TextComponentView.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/types.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts diff --git a/apps/ai-game-creator-shell/package-lock.json b/apps/ai-game-creator-shell/package-lock.json index a2d651d6c..bd7bbd328 100644 --- a/apps/ai-game-creator-shell/package-lock.json +++ b/apps/ai-game-creator-shell/package-lock.json @@ -21,6 +21,7 @@ "lucide-react": "^0.546.0", "react": "^19.0.0", "react-arborist": "^3.16.0", + "react-colorful": "^5.8.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", @@ -2273,19 +2274,19 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-http": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", - "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", - "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "node_modules/@tauri-apps/plugin-http": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", + "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0" @@ -4358,6 +4359,16 @@ "react-dom": "^16.9.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-colorful": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz", + "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dnd": { "version": "14.0.5", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e32f9c38d..5662ea4e4 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -47,6 +47,7 @@ "lucide-react": "^0.546.0", "react": "^19.0.0", "react-arborist": "^3.16.0", + "react-colorful": "^5.8.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts new file mode 100644 index 000000000..3a7bb52de --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts @@ -0,0 +1,279 @@ +import type { CSSProperties } from 'react'; + +import type { FillMethod } from '../types/FillMethod'; +import type { FontSizing } from '../types/FontSizing'; +import type { FontStyle } from '../types/FontStyle'; +import type { HorizontalTextOverflow } from '../types/HorizontalTextOverflow'; +import type { ImageComponent } from '../types/ImageComponent'; +import type { ImageType } from '../types/ImageType'; +import type { SpriteAsset } from '../types/SpriteAsset'; +import type { TextAlignment } from '../types/TextAlignment'; +import type { TextComponent } from '../types/TextComponent'; +import type { VerticalTextOverflow } from '../types/VerticalTextOverflow'; + +export const SYSTEM_UI_FONT_STACK = + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +export type ImageRenderModel = { + kind: 'image' | 'background' | 'filled'; + src: string; + style: CSSProperties; + imageStyle?: CSSProperties; + diagnostic?: string; +}; + +function finite(value: number, field: string): number { + if (!Number.isFinite(value)) { + throw new RangeError(`${field} must be finite`); + } + return value; +} + +function positive(value: number, field: string): number { + finite(value, field); + if (value <= 0) throw new RangeError(`${field} must be positive`); + return value; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, finite(value, 'amount'))); +} + +function logicalSize( + sprite: SpriteAsset, + multiplier = 1, +): { width: number; height: number } { + const pixelsPerUnit = positive(sprite.pixels_per_unit, 'pixels_per_unit'); + const scale = positive(multiplier, 'pixels_per_unit_multiplier'); + return { + width: + positive(sprite.pixel_size[0], 'pixel_size[0]') / (pixelsPerUnit * scale), + height: + positive(sprite.pixel_size[1], 'pixel_size[1]') / (pixelsPerUnit * scale), + }; +} + +function fillClipPath(method: FillMethod, amount: number): string | undefined { + if (amount >= 1) return undefined; + if (amount <= 0) return 'inset(0 0 0 100%)'; + + if ('Horizontal' in method) { + return method.Horizontal === 'Left' + ? `inset(0 ${(1 - amount) * 100}% 0 0)` + : `inset(0 0 0 ${(1 - amount) * 100}%)`; + } + if ('Vertical' in method) { + return method.Vertical === 'Top' + ? `inset(0 0 ${(1 - amount) * 100}% 0)` + : `inset(${(1 - amount) * 100}% 0 0 0)`; + } + + const [kind, config] = + 'Radial90' in method + ? (['Radial90', method.Radial90] as const) + : 'Radial180' in method + ? (['Radial180', method.Radial180] as const) + : (['Radial360', method.Radial360] as const); + const maxSweep = kind === 'Radial90' ? 90 : kind === 'Radial180' ? 180 : 360; + const sweep = amount * maxSweep; + const origin = config.origin; + const originAngle = + origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight' + ? 0 + : origin === 'Right' || origin === 'BottomRight' + ? 90 + : origin === 'Bottom' || origin === 'BottomLeft' + ? 180 + : 270; + const start = config.clockwise ? originAngle : originAngle - sweep; + // CSS conic gradients provide a deterministic browser preview for radial + // fills. Exact engine parity is intentionally deferred. + return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`; +} + +function imageFitStyle(preserveAspect: boolean): CSSProperties { + return { + width: '100%', + height: '100%', + objectFit: preserveAspect ? 'contain' : 'fill', + }; +} + +function imageTypeModel( + imageType: ImageType, + sprite: SpriteAsset, + src: string, +): ImageRenderModel { + if ('Simple' in imageType) { + return { + kind: 'image', + src, + style: { overflow: 'hidden' }, + imageStyle: imageFitStyle(imageType.Simple.preserve_aspect), + }; + } + + if ('Filled' in imageType) { + const amount = clamp01(imageType.Filled.amount); + const clip = fillClipPath(imageType.Filled.method, amount); + const imageStyle = imageFitStyle(imageType.Filled.preserve_aspect); + if (clip?.startsWith('conic-gradient')) { + imageStyle.maskImage = clip; + imageStyle.WebkitMaskImage = clip; + imageStyle.clipPath = undefined; + } else { + imageStyle.clipPath = clip; + } + return { + kind: 'filled', + src, + style: { overflow: 'hidden' }, + imageStyle, + }; + } + + const multiplier = + 'Sliced' in imageType + ? imageType.Sliced.pixels_per_unit_multiplier + : imageType.Tiled.pixels_per_unit_multiplier; + const size = logicalSize(sprite, multiplier); + + if ('Tiled' in imageType) { + return { + kind: 'background', + src, + style: { + backgroundImage: `url("${src}")`, + backgroundRepeat: 'repeat', + backgroundPosition: 'top left', + backgroundSize: `${size.width}px ${size.height}px`, + overflow: 'hidden', + }, + diagnostic: imageType.Tiled.fill_center + ? undefined + : 'Tiled fill_center=false uses the browser repeat fallback.', + }; + } + + const border = sprite.border; + const ppu = positive(sprite.pixels_per_unit, 'pixels_per_unit'); + const logicalBorderScale = + ppu * + positive( + imageType.Sliced.pixels_per_unit_multiplier, + 'pixels_per_unit_multiplier', + ); + const borderWidths = { + borderTopWidth: `${border.top / logicalBorderScale}px`, + borderRightWidth: `${border.right / logicalBorderScale}px`, + borderBottomWidth: `${border.bottom / logicalBorderScale}px`, + borderLeftWidth: `${border.left / logicalBorderScale}px`, + }; + return { + kind: 'background', + src, + style: { + ...borderWidths, + borderStyle: 'solid', + borderImageSource: `url("${src}")`, + borderImageSlice: `${border.top} ${border.right} ${border.bottom} ${border.left}${imageType.Sliced.fill_center ? ' fill' : ''}`, + borderImageWidth: `${border.top / logicalBorderScale}px ${border.right / logicalBorderScale}px ${border.bottom / logicalBorderScale}px ${border.left / logicalBorderScale}px`, + borderImageRepeat: 'stretch', + overflow: 'hidden', + }, + diagnostic: + border.left + border.right >= sprite.pixel_size[0] || + border.top + border.bottom >= sprite.pixel_size[1] + ? 'Sprite border leaves no center pixel; using best-effort CSS slicing.' + : undefined, + }; +} + +export function imageComponentToRenderModel( + component: ImageComponent, + sprite: SpriteAsset | null, + src: string | null, +): ImageRenderModel | null { + if (!src) return null; + if (!sprite) return null; + try { + return imageTypeModel(component.image_type, sprite, src); + } catch { + return { + kind: 'image', + src, + style: { overflow: 'hidden' }, + imageStyle: imageFitStyle(true), + diagnostic: '图片组件参数无效,已回退为等比预览。', + }; + } +} + +function fontSize(sizing: FontSizing): number { + if ('Fixed' in sizing) return Math.max(1, finite(sizing.Fixed, 'font size')); + // TODO: implement measured BestFit using node dimensions and text metrics. + return Math.max(1, finite(sizing.BestFit.max, 'BestFit.max')); +} + +function fontStyle( + style: FontStyle, +): Pick { + switch (style) { + case 'Bold': + return { fontStyle: 'normal', fontWeight: 700 }; + case 'Italic': + return { fontStyle: 'italic', fontWeight: 400 }; + case 'BoldItalic': + return { fontStyle: 'italic', fontWeight: 700 }; + case 'Normal': + return { fontStyle: 'normal', fontWeight: 400 }; + } +} + +function alignment( + alignmentValue: TextAlignment, +): Pick { + const vertical = alignmentValue.startsWith('Upper') + ? 'flex-start' + : alignmentValue.startsWith('Middle') + ? 'center' + : 'flex-end'; + const horizontal = alignmentValue.endsWith('Left') + ? 'flex-start' + : alignmentValue.endsWith('Center') + ? 'center' + : 'flex-end'; + return { + alignItems: vertical, + justifyContent: horizontal, + textAlign: + horizontal === 'flex-start' + ? 'left' + : horizontal === 'center' + ? 'center' + : 'right', + }; +} + +export function textComponentToCss(component: TextComponent): CSSProperties { + const [red, green, blue, alpha] = component.color; + const horizontalOverflow: HorizontalTextOverflow = + component.horizontal_overflow; + const verticalOverflow: VerticalTextOverflow = component.vertical_overflow; + return { + display: 'flex', + width: '100%', + height: '100%', + boxSizing: 'border-box', + padding: 0, + color: `rgba(${red}, ${green}, ${blue}, ${Math.min(255, Math.max(0, alpha)) / 255})`, + fontFamily: SYSTEM_UI_FONT_STACK, + fontSize: `${fontSize(component.font_sizing)}px`, + ...fontStyle(component.font_style), + ...alignment(component.alignment), + lineHeight: finite(component.line_spacing, 'line_spacing'), + whiteSpace: horizontalOverflow === 'Wrap' ? 'normal' : 'nowrap', + overflow: verticalOverflow === 'Truncate' ? 'hidden' : 'visible', + overflowWrap: horizontalOverflow === 'Wrap' ? 'anywhere' : 'normal', + }; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentField.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentField.tsx new file mode 100644 index 000000000..59c81252e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentField.tsx @@ -0,0 +1,95 @@ +import type { + InputHTMLAttributes, + ReactNode, + SelectHTMLAttributes, + TextareaHTMLAttributes, +} from 'react'; + +const CONTROL_BASE = + 'mt-1 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 text-xs text-(--platform-text-strong)'; +const INPUT_CLASS = `${CONTROL_BASE} h-9 px-2`; +const TEXTAREA_CLASS = `${CONTROL_BASE} min-h-20 resize-y px-2 py-2 leading-5`; + +export function ComponentField({ + label, + error, + children, +}: { + label: string; + error?: string; + children: ReactNode; +}) { + return ( + + ); +} + +export function ComponentInput({ + label, + error, + ...props +}: Omit, 'className'> & { + label: string; + error?: string; +}) { + return ( + + + + ); +} + +export function ComponentNumberInput({ + label, + error, + ...props +}: Omit, 'className' | 'type'> & { + label: string; + error?: string; +}) { + return ( + + ); +} + +export function ComponentTextarea({ + label, + error, + ...props +}: Omit, 'className'> & { + label: string; + error?: string; +}) { + return ( + +