From df22763b26f3e423cc762ee9699c2e6e4df64502 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 11:58:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E9=A1=B9=E7=9B=AE=E5=AD=97=E4=BD=93=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持项目字体导入、预览、引用计数和删除 补齐字体资源校验与字体引用回退系统字体 恢复 UI 编辑器前置检查操作 --- .../features/ui-editor/useUiEditorState.ts | 155 +++++++++++++++--- .../src/view/ui-editor/model.ts | 2 +- .../src/view/ui-editor/useUiEditorPage.ts | 85 +++++++++- 3 files changed, 219 insertions(+), 23 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index e42874cb3..7607f7387 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -2,6 +2,8 @@ import { useCallback, useRef, useState } from 'react'; import { validateSpriteBorder } from './spriteBorder'; import type { Component } from './types/Component'; +import type { FontAsset } from './types/FontAsset'; +import type { FontAssetId } from './types/FontAssetId'; import type { Node } from './types/Node'; import type { NodeId } from './types/NodeId'; import type { NodeMetadata } from './types/NodeMetadata'; @@ -248,12 +250,17 @@ export type RemovalImpact = { removedTreeCount: number; clearedSlaveToCount: number; clearedTargetGraphicCount: number; + clearedFontCount: number; }; function cloneState(state: State): State { return structuredClone(state); } +function sameResource(left: T, right: T): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + function visitComponents(nodes: Node[], visit: (component: Component) => void) { for (const node of nodes) { for (const component of node.components) { @@ -343,12 +350,6 @@ function isValidImageType(imageType: unknown): boolean { return false; } -function isValidFontSource(font: unknown): boolean { - if (font === 'SystemFont') return true; - const record = asRecord(font); - return record !== null && typeof record.Bound === 'string'; -} - function isValidComponent(component: Component): boolean { if ('Image' in component) { return ( @@ -375,13 +376,7 @@ function isValidComponent(component: Component): boolean { text.font_sizing.BestFit.min <= text.font_sizing.BestFit.max); return ( typeof text.content === 'string' && - isValidFontSource(text.font) && - isEnumValue(text.font_style, [ - 'Normal', - 'Bold', - 'Italic', - 'BoldItalic', - ] as const) && + (text.font === null || typeof text.font === 'string') && sizingValid && colorValid && isEnumValue(text.alignment, [ @@ -415,6 +410,7 @@ export function designImageRemovalImpact( (image) => image.metadata.slave_to === id, ).length, clearedTargetGraphicCount: 0, + clearedFontCount: 0, }; } @@ -435,6 +431,28 @@ export function spriteAssetRemovalImpact( removedTreeCount: 0, clearedSlaveToCount: 0, clearedTargetGraphicCount, + clearedFontCount: 0, + }; +} + +export function fontAssetRemovalImpact( + state: State, + id: FontAssetId, +): RemovalImpact { + let clearedFontCount = 0; + for (const tree of state.ui_trees) { + visitComponents([tree.root], (component) => { + if ('Text' in component && component.Text.font === id) { + clearedFontCount += 1; + } + }); + } + return { + removedResourceCount: id in state.font_assets ? 1 : 0, + removedTreeCount: 0, + clearedSlaveToCount: 0, + clearedTargetGraphicCount: 0, + clearedFontCount, }; } @@ -464,6 +482,30 @@ function spriteResourceValidationError(sprite: SpriteAsset) { return border.ok ? null : border.message; } +function fontResourceValidationError(font: FontAsset) { + if (font.asset_id.trim().length === 0) return '缺少字体 ID'; + if (font.path.trim().length === 0) return '缺少字体路径'; + if (!/^[a-f0-9]{64}$/u.test(font.content_sha256)) return '字体摘要无效'; + if (font.metadata.family_name.trim().length === 0) return '缺少字体家族名'; + if (font.metadata.face_name.trim().length === 0) return '缺少字体面名称'; + if (font.metadata.source_file_name.trim().length === 0) { + return '缺少字体源文件名'; + } + if ( + !Number.isInteger(font.metadata.weight) || + font.metadata.weight < 1 || + font.metadata.weight > 1000 + ) { + return '字体 weight 无效'; + } + if ( + !['TrueType', 'OpenType', 'Woff', 'Woff2'].includes(font.metadata.format) + ) { + return '字体格式无效'; + } + return null; +} + export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const [state, setState] = useState(() => { const next = cloneState(initialState); @@ -624,14 +666,16 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const blocked = guard(); if (blocked) return blocked; const current = stateRef.current; - const ids = assets.map((asset) => asset.asset_id); - if ( - new Set(ids).size !== ids.length || - ids.some((id) => id in current.sprite_assets) - ) { - return { ok: false, reason: 'duplicate' }; + const unique = new Map(); + for (const asset of assets) { + const candidate = + unique.get(asset.asset_id) ?? current.sprite_assets[asset.asset_id]; + if (candidate && !sameResource(candidate, asset)) { + return { ok: false, reason: 'duplicate' }; + } + unique.set(asset.asset_id, asset); } - const invalidSprite = assets + const invalidSprite = [...unique.values()] .map((asset) => ({ asset, error: spriteResourceValidationError(asset), @@ -644,7 +688,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { }; } const next = cloneState(current); - for (const asset of assets) { + for (const asset of unique.values()) { next.sprite_assets[asset.asset_id] = structuredClone(asset); } commit(next); @@ -653,6 +697,45 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); + const addFontAssets = useCallback( + (assets: readonly FontAsset[]): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const unique = new Map(); + for (const asset of assets) { + const candidate = + unique.get(asset.asset_id) ?? current.font_assets[asset.asset_id]; + if (candidate && !sameResource(candidate, asset)) { + return { ok: false, reason: 'duplicate' }; + } + unique.set(asset.asset_id, asset); + } + const newCount = [...unique.keys()].filter( + (id) => !(id in current.font_assets), + ).length; + if (Object.keys(current.font_assets).length + newCount > 64) { + return { ok: false, reason: 'limit' }; + } + const invalidFont = [...unique.values()] + .map((asset) => ({ asset, error: fontResourceValidationError(asset) })) + .find((item) => item.error); + if (invalidFont?.error) { + return { + ok: false, + reason: `invalid:${invalidFont.asset.asset_id}:${invalidFont.error}`, + }; + } + const next = cloneState(current); + for (const asset of unique.values()) { + next.font_assets[asset.asset_id] = structuredClone(asset); + } + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + const setSpriteName = useCallback( (id: SpriteAssetId, name: string): UiEditorOperationResult => { const blocked = guard(); @@ -1205,6 +1288,34 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); + const removeFontAsset = useCallback( + ( + id: FontAssetId, + options: { dryRun: boolean }, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.font_assets)) { + return { ok: false, reason: 'missing' }; + } + const impact = fontAssetRemovalImpact(current, id); + if (options.dryRun) return { ok: true, value: impact }; + const next = cloneState(current); + delete next.font_assets[id]; + for (const tree of next.ui_trees) { + visitComponents([tree.root], (component) => { + if ('Text' in component && component.Text.font === id) { + component.Text.font = 'SystemFont'; + } + }); + } + commit(next); + return { ok: true, value: impact }; + }, + [commit, guard], + ); + const clearState = useCallback((): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; @@ -1232,6 +1343,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { setImageSlaveTo, addDesignImages, addSpriteAssets, + addFontAssets, setSpriteName, setSpriteAssetType, setSpriteBorder, @@ -1247,6 +1359,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { moveNode, removeDesignImage, removeSpriteAsset, + removeFontAsset, clearState, replaceState, }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts index f4f290684..99e4c86f5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts @@ -5,7 +5,7 @@ import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState'; export type UiEditorStepId = 'reference-analysis' | 'structure-recognition' | 'visual-binding'; -export type UiEditorImportKind = 'design-image' | 'sprite'; +export type UiEditorImportKind = 'design-image' | 'font' | 'sprite'; export type UiEditorNodeFocusRequest = { treeId: UIDesignImageId; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 2151895f0..55193a7d9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -5,6 +5,7 @@ import type { ImportedAsset } from '../../components/AssetImporter'; import { applyBindingResult } from '../../features/ui-editor/binding'; import { prepareDesignImageBatch, + prepareFontAssetBatch, prepareSpriteAssetBatch, } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; @@ -12,6 +13,7 @@ import { type UiEditorPrerequisiteIssue, validateAssetRecognitionPrerequisites, validateComponentRecognitionPrerequisites, + validateLayoutReviewPrerequisites, validateReferenceAnalysisResult, validateStructureRecognitionResult, validateVisualBindingResult, @@ -19,6 +21,7 @@ import { import { applyRecognitionResult } from '../../features/ui-editor/recognition'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { Component } from '../../features/ui-editor/types/Component'; +import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId'; import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO'; import type { Node as UiNode } from '../../features/ui-editor/types/Node'; import type { NodeId } from '../../features/ui-editor/types/NodeId'; @@ -33,6 +36,7 @@ import { type UiDesignStateStore, } from '../../features/ui-editor/uiDesignStateStore'; import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions'; +import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces'; import { EMPTY_UI_EDITOR_STATE, type NodeMetadataPatch, @@ -82,6 +86,9 @@ export function useUiEditorPage( ); const [selectedSpriteId, setSelectedSpriteId] = useState(null); + const [selectedFontId, setSelectedFontId] = useState( + null, + ); const [selectedNodeId, setSelectedNodeId] = useState(null); const { focusRequest, focusNode } = useUiEditorNodeFocus({ activateImage: setActiveImageId, @@ -199,8 +206,11 @@ export function useUiEditorPage( const images = editor.state.ui_design_images; const sprites = editor.state.sprite_assets; + const fonts = editor.state.font_assets; + const fontFaces = useUiEditorFontFaces(projectPath, fonts); const activeImage = activeImageId ? images[activeImageId] : null; const selectedSprite = selectedSpriteId ? sprites[selectedSpriteId] : null; + const selectedFont = selectedFontId ? fonts[selectedFontId] : null; const pageOptions = Object.entries(images).filter( ([, image]) => image.metadata.role === 'Page', ); @@ -229,6 +239,23 @@ export function useUiEditorPage( return counts; }, [editor.state.ui_trees]); + const fontReferenceCounts = useMemo(() => { + const counts: Record = {}; + function visit(nodes: UiNode[]) { + for (const node of nodes) { + for (const component of node.components) { + if ('Text' in component && typeof component.Text.font !== 'string') { + const id = component.Text.font.Bound; + counts[id] = (counts[id] ?? 0) + 1; + } + } + visit(node.children); + } + } + for (const tree of editor.state.ui_trees) visit([tree.root]); + return counts; + }, [editor.state.ui_trees]); + const treeForActiveImage = activeImageId ? editor.state.ui_trees.find((tree) => tree.src_ui_design === activeImageId) : null; @@ -294,6 +321,20 @@ export function useUiEditorPage( return; } + if (importKind === 'font') { + const prepared = await prepareFontAssetBatch(projectPath, imported); + const result = editor.addFontAssets(prepared); + if (!result.ok) { + setStatus(uiEditorOperationError(result.reason)); + return; + } + setSelectedFontId( + (current) => current ?? prepared[0]?.asset_id ?? null, + ); + setStatus(`已加入 ${prepared.length} 项项目字体。`); + return; + } + const prepared = await prepareSpriteAssetBatch(projectPath, imported); const result = editor.addSpriteAssets( prepared.map((item) => item.resource), @@ -321,6 +362,7 @@ export function useUiEditorPage( return next; }); if (id === selectedSpriteId) setSelectedSpriteId(null); + if (id === selectedFontId) setSelectedFontId(null); if (id !== activeImageId) return; setSelectedNodeId(null); const index = imageOrder.indexOf(id); @@ -351,12 +393,25 @@ export function useUiEditorPage( removeFromUiSession(id); } + function requestFontRemoval(id: FontAssetId) { + const result = editor.removeFontAsset(id, { dryRun: true }); + if (!result.ok) return; + if (removalHasDownstreamReferences(result.value)) { + setPendingRemoval({ kind: 'font', id, impact: result.value }); + return; + } + editor.removeFontAsset(id, { dryRun: false }); + removeFromUiSession(id); + } + function confirmRemoval() { if (!pendingRemoval) return; if (pendingRemoval.kind === 'design-image') { editor.removeDesignImage(pendingRemoval.id, { dryRun: false }); - } else { + } else if (pendingRemoval.kind === 'sprite') { editor.removeSpriteAsset(pendingRemoval.id, { dryRun: false }); + } else { + editor.removeFontAsset(pendingRemoval.id, { dryRun: false }); } removeFromUiSession(pendingRemoval.id); setPendingRemoval(null); @@ -421,6 +476,20 @@ export function useUiEditorPage( ]; } + function checkPrerequisites() { + const issues = + activeStep === 'reference-analysis' + ? validateComponentRecognitionPrerequisites(editor.state) + : activeStep === 'structure-recognition' + ? validateAssetRecognitionPrerequisites(editor.state) + : validateLayoutReviewPrerequisites(editor.state); + setStatus( + issues.length === 0 + ? '前置数据检查通过。' + : issues.map((issue) => issue.message).join(';'), + ); + } + function clearState() { editor.clearState(); setImageOrder([]); @@ -572,6 +641,12 @@ export function useUiEditorPage( setSelectedSpriteId(id); } + function selectFont(id: FontAssetId) { + setSelectedNodeId(null); + setSelectedSpriteId(null); + setSelectedFontId(id); + } + function setImageName(name: string) { if (!activeImageId) return; editor.setImageName(activeImageId, name); @@ -727,6 +802,7 @@ export function useUiEditorPage( imageOrder, activeImageId, selectedSpriteId, + selectedFontId, selectedNodeId, focusRequest, importKind, @@ -737,10 +813,14 @@ export function useUiEditorPage( pendingRemoval, images, sprites, + fonts, + fontFaces, activeImage, selectedSprite, + selectedFont, pageOptions, spriteReferenceCounts, + fontReferenceCounts, treeForActiveImage, selectedNode: selectedNodeContext?.node ?? null, selectedNodeParentSize: selectedNodeContext?.parentSize, @@ -753,8 +833,11 @@ export function useUiEditorPage( confirmStepChange, cancelStepChange: () => setPendingWorkflowStepChange(null), postCheckIssuesForSave, + checkPrerequisites, selectDesignImage, selectSprite, + selectFont, + requestFontRemoval, selectNode, focusNode, clearNodeSelection,