完善 UI 编辑器项目字体闭环

支持项目字体导入、预览、引用计数和删除

补齐字体资源校验与字体引用回退系统字体

恢复 UI 编辑器前置检查操作
This commit is contained in:
2026-08-18 11:58:07 +08:00
parent d1664472d5
commit df22763b26
3 changed files with 219 additions and 23 deletions
@@ -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<T>(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<State>(() => {
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<SpriteAssetId, SpriteAsset>();
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<FontAssetId, FontAsset>();
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<RemovalImpact> => {
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,
};
@@ -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;
@@ -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<SpriteAssetId | null>(null);
const [selectedFontId, setSelectedFontId] = useState<FontAssetId | null>(
null,
);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(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<string, number> = {};
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,