From 892c515d09f8976571bc922f37b61143ac3fa45a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 18 Aug 2026 19:20:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=85=A5=E5=99=A8?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E9=A2=84=E8=A7=88=E4=B8=8E=E6=A0=B9=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一图片与字体导入器的本地项目根目录 将导入器预览作为 settings 组件传入,导入器不判断资产类型 复用 Inspector 字体加载链路和精灵图片呈现 在字体预览中展示黑色多字号中英文数字标点样张 --- .../src-tauri/src/commands.rs | 13 -- .../src-tauri/src/main.rs | 1 - .../AssetImporter/FontImporterPreview.tsx | 85 +++++++++++ .../AssetImporter/ImageImporterPreview.tsx | 72 ++++++++++ .../src/components/AssetImporter/index.tsx | 136 ++---------------- .../src/components/AssetImporter/settings.ts | 8 +- .../src/components/AssetImporter/utils.ts | 10 +- .../components/SpriteImagePreview.tsx | 12 ++ .../ui-editor/useUiEditorFontFaces.ts | 94 ++++++++---- .../Inspector/Components/TextPanel.tsx | 35 +++-- .../components/Inspector/InspectorSidebar.tsx | 13 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 12 files changed, 292 insertions(+), 191 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fc36aa94f..a4aa7e7e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1996,19 +1996,6 @@ pub(crate) fn read_ui_editor_font_bytes( .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) } -#[tauri::command] -pub(crate) fn read_ui_editor_font_preview( - project_path: String, - asset_id: String, - relative_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "file.read")?; - let manifest = read_existing_manifest_for_project(root)?; - let asset = find_registered_ui_editor_font(&manifest, asset_id.trim(), relative_path.trim())?; - read_registered_ui_editor_font(root, asset).map(|(_, bytes)| tauri::ipc::Response::new(bytes)) -} - #[tauri::command] pub(crate) fn check_ui_editor_font_glyph_coverage( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 253e5edea..0290151e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2301,7 +2301,6 @@ fn main() { import_ui_editor_assets, prepare_ui_editor_project_fonts, read_ui_editor_font_bytes, - read_ui_editor_font_preview, check_ui_editor_font_glyph_coverage, suggest_ui_design_semantic, recognize_ui, diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx new file mode 100644 index 000000000..865269731 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx @@ -0,0 +1,85 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useEffect, useRef, useState } from 'react'; + +import type { FontAsset } from '../../features/ui-editor/types/FontAsset'; +import { + type LoadedUiEditorFontFace, + loadUiEditorFontFace, +} from '../../features/ui-editor/useUiEditorFontFaces'; +import type { AssetImporterPreviewProps } from './utils'; + +export function FontImporterPreview({ + projectPath, + selected, +}: AssetImporterPreviewProps) { + const [fontFamily, setFontFamily] = useState(null); + const [error, setError] = useState(null); + const loadedRef = useRef<{ face: FontFace; url: string } | null>(null); + + useEffect(() => { + let cancelled = false; + let loaded: LoadedUiEditorFontFace | null = null; + if (loadedRef.current) { + document.fonts?.delete(loadedRef.current.face); + URL.revokeObjectURL(loadedRef.current.url); + loadedRef.current = null; + } + setFontFamily(null); + setError(null); + if (!selected?.asset || selected.isDirectory) return; + void (async () => { + try { + const [font] = await invoke( + 'prepare_ui_editor_project_fonts', + { projectPath, assets: [selected.asset] }, + ); + if (!font) throw new Error('未取得字体元数据'); + loaded = await loadUiEditorFontFace(projectPath, font); + if (cancelled) { + URL.revokeObjectURL(loaded.url); + return; + } + document.fonts.add(loaded.face); + loadedRef.current = loaded; + setFontFamily(loaded.cssFamily); + } catch (cause) { + console.error('[ui-editor-font-preview] failed', { + assetId: selected.asset?.id, + relativePath: selected.asset?.localPath, + cause, + }); + if (!cancelled) setError('字体样张加载失败'); + } + })(); + return () => { + cancelled = true; + if (loadedRef.current === loaded && loaded) { + document.fonts?.delete(loaded.face); + URL.revokeObjectURL(loaded.url); + loadedRef.current = null; + } + }; + }, [projectPath, selected]); + + if (!fontFamily) { + return ( + + {error ?? selected?.name ?? '选择一个字体文件'} + + ); + } + return ( +
+

+ 中文字体预览 · English Font Preview · 0123456789 +

+

+ 春夏秋冬,山川湖海;Aa Bb Cc,!? @#&*() +

+

陶泥儿 Genarrative Studio

+

你好,Hello World! 字体设计

+

字体样张 Aa 字母数字 123

+

中文 English

+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx new file mode 100644 index 000000000..7b3e2150a --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx @@ -0,0 +1,72 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useEffect, useRef, useState } from 'react'; + +import { SpriteImagePreview } from '../../features/ui-editor/components/SpriteImagePreview'; +import { resolveClientAssetReadUrl } from '../../services/clientApi'; +import { + cancelLocalProjectResourcePreviewScope, + createProjectResourcePreviewRequestId, + createProjectResourcePreviewScopeId, +} from '../../services/projectResourcePreviewTransport'; +import type { AssetImporterPreviewProps } from './utils'; + +type ImagePreviewResponse = { dataUrl: string }; + +export function ImageImporterPreview({ + projectPath, + selected, +}: AssetImporterPreviewProps) { + const [previewUrl, setPreviewUrl] = useState(null); + const scopeIdRef = useRef(createProjectResourcePreviewScopeId()); + + useEffect(() => { + const previousScopeId = scopeIdRef.current; + scopeIdRef.current = createProjectResourcePreviewScopeId(); + cancelLocalProjectResourcePreviewScope(previousScopeId); + return () => cancelLocalProjectResourcePreviewScope(scopeIdRef.current); + }, [projectPath]); + + useEffect(() => { + if (!selected || selected.isDirectory) { + setPreviewUrl(null); + return; + } + if (selected.previewUrl) { + setPreviewUrl(selected.previewUrl); + return; + } + if (selected.source === 'remote' && selected.remoteObjectKey) { + void resolveClientAssetReadUrl(selected.remoteObjectKey) + .then(setPreviewUrl) + .catch(() => setPreviewUrl(null)); + return; + } + if ( + selected.source === 'remote' && + /^https?:\/\//iu.test(selected.asset?.localPath ?? '') + ) { + setPreviewUrl(selected.asset?.localPath ?? null); + return; + } + void invoke('read_local_project_image_preview', { + projectPath, + relativePath: selected.asset?.localPath, + scopeId: scopeIdRef.current, + requestId: createProjectResourcePreviewRequestId(), + }) + .then((value) => setPreviewUrl(value.dataUrl)) + .catch(() => setPreviewUrl(null)); + }, [projectPath, selected]); + + return previewUrl ? ( + + ) : ( + + {selected?.name ?? '选择一个文件'} + + ); +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index d9301b81d..d166d560a 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -9,13 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { loadEditorAssetLibrary, readClientAssetBytes, - resolveClientAssetReadUrl, } from '../../services/clientApi'; -import { - cancelLocalProjectResourcePreviewScope, - createProjectResourcePreviewRequestId, - createProjectResourcePreviewScopeId, -} from '../../services/projectResourcePreviewTransport'; import { ThemedModal } from '../modal/ThemedModal'; import { type AssetImporterSettings, @@ -38,7 +32,10 @@ export type { RemoteAssetCandidate, } from './utils'; -const ROOT_PATH = ''; +// FileManager treats an initial `/` as its Home location, then resolves its +// visible root entries from the empty internal path. Keep this value aligned +// with the image importer behaviour that predates the shared component. +const ROOT_PATH = '/'; export type AssetImporterProps = { open: boolean; @@ -57,8 +54,6 @@ type LocalManifestResponse = { type LocalProjectFilesResponse = { files?: LocalProjectFile[] }; -type ImagePreviewResponse = { dataUrl: string }; - type ImportAssetResponse = { id: string; localPath: string; @@ -75,29 +70,15 @@ export function AssetImporter({ settings, }: AssetImporterProps) { const maxItems = settings.local.requirements.maxItems; + const Preview = settings.preview; const maxFileSize = settings.local.requirements.maxFileSizeBytes ?? 0; const [files, setFiles] = useState([]); const [selected, setSelected] = useState([]); - const [preview, setPreview] = useState(null); - const [fontPreviewFamily, setFontPreviewFamily] = useState( - null, - ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [currentPath, setCurrentPath] = useState(ROOT_PATH); const currentPathRef = useRef(ROOT_PATH); const remoteLoadedRef = useRef(false); - const previewScopeIdRef = useRef(createProjectResourcePreviewScopeId()); - const previewFontFaceRef = useRef(null); - - useEffect(() => { - const previousScopeId = previewScopeIdRef.current; - cancelLocalProjectResourcePreviewScope(previousScopeId); - previewScopeIdRef.current = createProjectResourcePreviewScopeId(); - return () => { - cancelLocalProjectResourcePreviewScope(previewScopeIdRef.current); - }; - }, [projectPath]); const loadLocal = useCallback(async () => { setError(null); @@ -224,93 +205,9 @@ export function AssetImporter({ setCurrentPath(ROOT_PATH); setFiles(buildRootFiles(settings)); setSelected([]); - setPreview(null); - if (previewFontFaceRef.current) { - document.fonts?.delete(previewFontFaceRef.current); - previewFontFaceRef.current = null; - } - setFontPreviewFamily(null); void refresh(ROOT_PATH); }, [open, refresh, settings]); - useEffect(() => { - const item = selected.at(-1); - if (previewFontFaceRef.current) { - document.fonts?.delete(previewFontFaceRef.current); - previewFontFaceRef.current = null; - } - setFontPreviewFamily(null); - if (!item || item.isDirectory) { - setPreview(null); - return; - } - if (settings.local.localPolicy.destination === 'assets/fonts') { - setPreview(null); - void (async () => { - try { - if ( - typeof FontFace === 'undefined' || - !document.fonts || - !item.asset - ) { - return; - } - const bytes = await invoke( - 'read_ui_editor_font_preview', - { - projectPath, - assetId: item.asset.id, - relativePath: item.asset.localPath, - }, - ); - const family = `asset-importer-preview-${item.asset.id}`; - const face = new FontFace(family, bytes); - await face.load(); - previewFontFaceRef.current = face; - document.fonts.add(face); - setFontPreviewFamily(family); - } catch { - setFontPreviewFamily(null); - } - })(); - return; - } - if (item.previewUrl) { - setPreview(item.previewUrl); - return; - } - if (item.source === 'remote' && item.remoteObjectKey) { - void resolveClientAssetReadUrl(item.remoteObjectKey) - .then(setPreview) - .catch(() => setPreview(null)); - return; - } - if ( - item.source === 'remote' && - /^https?:\/\//iu.test(item.asset?.localPath ?? '') - ) { - setPreview(item.asset?.localPath ?? null); - return; - } - void invoke('read_local_project_image_preview', { - projectPath, - relativePath: item.asset?.localPath, - scopeId: previewScopeIdRef.current, - requestId: createProjectResourcePreviewRequestId(), - }) - .then((value) => setPreview(value.dataUrl)) - .catch(() => setPreview(null)); - }, [projectPath, selected, settings.local.localPolicy.destination]); - - useEffect( - () => () => { - if (previewFontFaceRef.current) { - document.fonts?.delete(previewFontFaceRef.current); - } - }, - [], - ); - const pickLocal = async () => { setError(null); setLoading(true); @@ -415,7 +312,7 @@ export function AssetImporter({ void pickLocal(); }; const handleFolderChange = (path: string) => { - const nextPath = path === '/' ? ROOT_PATH : path; + const nextPath = path || ROOT_PATH; currentPathRef.current = nextPath; setCurrentPath(nextPath); if ( @@ -466,7 +363,7 @@ export function AssetImporter({ ) : null}

预览

- {preview ? ( - 选中图片预览 - ) : ( - - {selected.at(-1)?.name ?? '选择一个文件'} - - )} +

diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts index 87049d624..c6f3611aa 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts @@ -1,6 +1,8 @@ import { Image as ImageIcon, Type } from 'lucide-react'; import { createElement } from 'react'; +import { FontImporterPreview } from './FontImporterPreview'; +import { ImageImporterPreview } from './ImageImporterPreview'; import type { AssetImporterSettings, ImportRequirements, @@ -61,8 +63,9 @@ function imageImporterSettings( title: '导入图片素材', ariaLabel: '导入图片素材', icon: createElement(ImageIcon, { size: 16 }), + preview: ImageImporterPreview, local: { - label: '本地项目素材', + label: '本地项目', typeFilter: imageLocalTypeFilter, fileDialog: { title: '选择图片素材', @@ -98,8 +101,9 @@ export const FONT_IMPORTER_SETTINGS: AssetImporterSettings = { title: '导入字体', ariaLabel: '导入字体', icon: createElement(Type, { size: 16 }), + preview: FontImporterPreview, local: { - label: '本地项目字体', + label: '本地项目', typeFilter: fontLocalTypeFilter, fileDialog: { title: '选择字体文件', diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts index 325533397..df73e7573 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -1,5 +1,5 @@ import type { FileManagerFile } from '@cubone/react-file-manager'; -import type { ReactNode } from 'react'; +import type { ComponentType, ReactNode } from 'react'; export type ImportedAsset = { id: string; @@ -66,6 +66,7 @@ export type AssetImporterSettings = { icon?: ReactNode; local: LocalImporterSettings; remote?: RemoteImporterSettings; + preview: ComponentType; }; export type ManagerFile = FileManagerFile & { @@ -75,6 +76,11 @@ export type ManagerFile = FileManagerFile & { asset?: ImportedAsset; }; +export type AssetImporterPreviewProps = { + projectPath: string; + selected: ManagerFile | undefined; +}; + export type ManifestAsset = { id: string; localPath: string; @@ -95,7 +101,7 @@ type RemoteAsset = { }; type RemoteLibrary = { folders?: RemoteFolder[]; assets?: RemoteAsset[] }; -export const PROJECT_ASSETS_PATH = '/本地项目素材'; +export const PROJECT_ASSETS_PATH = '/本地项目'; export const REMOTE_ASSETS_PATH = '/云端素材库'; function imagePath(path: string) { return path.replace(/^\/+/, '').replaceAll('\\', '/'); diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx new file mode 100644 index 000000000..a724c4e42 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx @@ -0,0 +1,12 @@ +export function SpriteImagePreview({ + src, + alt, + className, +}: { + src: string | undefined | null; + alt: string; + className?: string; +}) { + if (!src) return null; + return {alt}; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts index 0696ee745..98f7cdda7 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts @@ -8,6 +8,12 @@ export type UiEditorFontFaceState = { status: 'error' | 'loaded' | 'loading'; }; +export type LoadedUiEditorFontFace = { + cssFamily: string; + face: FontFace; + url: string; +}; + export function uiEditorPrivateFontFamily(assetId: string) { return `ui-editor-font-${encodeURIComponent(assetId).replaceAll('%', '_')}`; } @@ -25,6 +31,58 @@ function fontMimeType(font: FontAsset) { } } +export async function loadUiEditorFontFace( + projectPath: string, + font: FontAsset, +): Promise { + if (typeof FontFace === 'undefined' || !document.fonts) { + throw new Error('FontFace unavailable'); + } + const cssFamily = uiEditorPrivateFontFamily(font.asset_id); + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.info('[ui-editor-font-face] reading font bytes', { + assetId: font.asset_id, + relativePath: font.path, + }); + const bytes = await invoke('read_ui_editor_font_bytes', { + projectPath, + assetId: font.asset_id, + relativePath: font.path, + expectedSha256: font.content_sha256, + }); + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.info('[ui-editor-font-face] received font bytes', { + assetId: font.asset_id, + bytes: bytes.byteLength, + }); + const url = URL.createObjectURL( + new Blob([new Uint8Array(bytes)], { type: fontMimeType(font) }), + ); + const face = new FontFace(cssFamily, `url(${JSON.stringify(url)})`, { + style: font.metadata.italic ? 'italic' : 'normal', + weight: String(font.metadata.weight), + }); + try { + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.info('[ui-editor-font-face] loading FontFace', { + assetId: font.asset_id, + cssFamily, + }); + await face.load(); + return { cssFamily, face, url }; + } catch (cause) { + document.fonts.delete(face); + URL.revokeObjectURL(url); + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.error('[ui-editor-font-face] FontFace load failed', { + assetId: font.asset_id, + relativePath: font.path, + cause, + }); + throw cause; + } +} + export function useUiEditorFontFaces( projectPath: string, fonts: Record, @@ -69,43 +127,23 @@ export function useUiEditorFontFaces( for (const font of fontAssets) { void (async () => { const cssFamily = uiEditorPrivateFontFamily(font.asset_id); - let url: string | undefined; - let face: FontFace | undefined; + let loaded: LoadedUiEditorFontFace | undefined; try { - if (typeof FontFace === 'undefined' || !document.fonts) { - throw new Error('FontFace unavailable'); - } - const bytes = await invoke('read_ui_editor_font_bytes', { - projectPath, - assetId: font.asset_id, - relativePath: font.path, - expectedSha256: font.content_sha256, - }); - if (cancelled) return; - url = URL.createObjectURL( - new Blob([new Uint8Array(bytes)], { type: fontMimeType(font) }), - ); - face = new FontFace(cssFamily, `url(${JSON.stringify(url)})`, { - style: font.metadata.italic ? 'italic' : 'normal', - weight: String(font.metadata.weight), - }); - await face.load(); + loaded = await loadUiEditorFontFace(projectPath, font); if (cancelled) { - URL.revokeObjectURL(url); + URL.revokeObjectURL(loaded.url); return; } - document.fonts.add(face); - registered.push({ face, url }); + document.fonts.add(loaded.face); + registered.push({ face: loaded.face, url: loaded.url }); setStates((current) => ({ ...current, [font.asset_id]: { cssFamily, status: 'loaded' }, })); } catch { - if (face) { - document.fonts?.delete(face); - } - if (url) { - URL.revokeObjectURL(url); + if (loaded) { + document.fonts?.delete(loaded.face); + URL.revokeObjectURL(loaded.url); } if (!cancelled) { setStates((current) => ({ diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index 9f0d5df7e..9285614f4 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -45,14 +45,29 @@ export function TextPanel({ onChange({ ...component, content: event.target.value }) } /> - - {/* TODO: expose editable imported font assets after FontAsset gets a safe browser resource. */} -

- {typeof component.font !== 'string' - ? (fonts[component.font.Bound]?.asset_id ?? component.font.Bound) - : '系统字体'} -
- + + onChange({ + ...component, + font: + event.target.value === 'SystemFont' + ? 'SystemFont' + : { Bound: event.target.value }, + }) + } + > + + {Object.values(fonts) + .sort((left, right) => left.asset_id.localeCompare(right.asset_id)) + .map((font) => ( + + ))} +
- {previewUrl ? ( - {sprite.metadata.name} - ) : null} +