完善 UI 编辑器项目字体闭环
支持项目字体导入、预览、引用计数和删除 补齐字体资源校验与字体引用回退系统字体 恢复 UI 编辑器前置检查操作
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user