修复导入器字体预览与根目录
统一图片与字体导入器的本地项目根目录 将导入器预览作为 settings 组件传入,导入器不判断资产类型 复用 Inspector 字体加载链路和精灵图片呈现 在字体预览中展示黑色多字号中英文数字标点样张
This commit is contained in:
@@ -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<tauri::ipc::Response, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<FontAsset[]>(
|
||||
'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 (
|
||||
<span className="px-4 text-center text-xs text-(--platform-text-soft)">
|
||||
{error ?? selected?.name ?? '选择一个字体文件'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full px-5 py-6" style={{ color: '#000', fontFamily }}>
|
||||
<p className="m-0 text-xs leading-5">
|
||||
中文字体预览 · English Font Preview · 0123456789
|
||||
</p>
|
||||
<p className="m-0 mt-3 text-sm leading-6">
|
||||
春夏秋冬,山川湖海;Aa Bb Cc,!? @#&*()
|
||||
</p>
|
||||
<p className="m-0 mt-3 text-lg leading-7">陶泥儿 Genarrative Studio</p>
|
||||
<p className="m-0 mt-3 text-2xl leading-8">你好,Hello World! 字体设计</p>
|
||||
<p className="m-0 mt-3 text-3xl leading-10">字体样张 Aa 字母数字 123</p>
|
||||
<p className="m-0 mt-3 text-4xl leading-none">中文 English</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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<ImagePreviewResponse>('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 ? (
|
||||
<SpriteImagePreview
|
||||
src={previewUrl}
|
||||
alt="选中图片预览"
|
||||
className="max-h-72 max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-(--platform-text-soft)">
|
||||
{selected?.name ?? '选择一个文件'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<ManagerFile[]>([]);
|
||||
const [selected, setSelected] = useState<ManagerFile[]>([]);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [fontPreviewFamily, setFontPreviewFamily] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState(ROOT_PATH);
|
||||
const currentPathRef = useRef(ROOT_PATH);
|
||||
const remoteLoadedRef = useRef(false);
|
||||
const previewScopeIdRef = useRef(createProjectResourcePreviewScopeId());
|
||||
const previewFontFaceRef = useRef<FontFace | null>(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<ArrayBuffer>(
|
||||
'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<ImagePreviewResponse>('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}
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
<FileManager
|
||||
key={`${settings.ariaLabel}:${currentPath}`}
|
||||
key={`${settings.ariaLabel}:${open ? 'open' : 'closed'}`}
|
||||
style={{ minHeight: 0, height: '100%', width: '100%' }}
|
||||
files={files}
|
||||
layout="grid"
|
||||
@@ -499,24 +396,7 @@ export function AssetImporter({
|
||||
<aside className="flex w-72 shrink-0 flex-col border-l border-(--platform-subpanel-border) p-4">
|
||||
<h3 className="text-xs font-semibold">预览</h3>
|
||||
<div className="mt-3 grid min-h-48 place-items-center overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-black/3">
|
||||
{preview ? (
|
||||
<img
|
||||
src={preview}
|
||||
alt="选中图片预览"
|
||||
className="max-h-72 max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs text-(--platform-text-soft)"
|
||||
style={
|
||||
fontPreviewFamily
|
||||
? { fontFamily: fontPreviewFamily, fontSize: '1rem' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selected.at(-1)?.name ?? '选择一个文件'}
|
||||
</span>
|
||||
)}
|
||||
<Preview projectPath={projectPath} selected={selected.at(-1)} />
|
||||
</div>
|
||||
<div className="mt-auto pt-4">
|
||||
<p className="m-0 text-xs leading-5 text-(--platform-text-soft)">
|
||||
|
||||
@@ -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: '选择字体文件',
|
||||
|
||||
@@ -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<AssetImporterPreviewProps>;
|
||||
};
|
||||
|
||||
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('\\', '/');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function SpriteImagePreview({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src: string | undefined | null;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!src) return null;
|
||||
return <img src={src} alt={alt} className={className} />;
|
||||
}
|
||||
@@ -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<LoadedUiEditorFontFace> {
|
||||
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<ArrayBuffer>('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<string, FontAsset>,
|
||||
@@ -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<ArrayBuffer>('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) => ({
|
||||
|
||||
+27
-8
@@ -45,14 +45,29 @@ export function TextPanel({
|
||||
onChange({ ...component, content: event.target.value })
|
||||
}
|
||||
/>
|
||||
<ComponentField label="字体资源">
|
||||
{/* TODO: expose editable imported font assets after FontAsset gets a safe browser resource. */}
|
||||
<div className="mt-1 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs font-normal">
|
||||
{typeof component.font !== 'string'
|
||||
? (fonts[component.font.Bound]?.asset_id ?? component.font.Bound)
|
||||
: '系统字体'}
|
||||
</div>
|
||||
</ComponentField>
|
||||
<ComponentSelect
|
||||
label="字体资源"
|
||||
value={fontSourceValue(component.font)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...component,
|
||||
font:
|
||||
event.target.value === 'SystemFont'
|
||||
? 'SystemFont'
|
||||
: { Bound: event.target.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="SystemFont">系统字体</option>
|
||||
{Object.values(fonts)
|
||||
.sort((left, right) => left.asset_id.localeCompare(right.asset_id))
|
||||
.map((font) => (
|
||||
<option key={font.asset_id} value={font.asset_id}>
|
||||
{font.metadata.family_name} {font.metadata.face_name}
|
||||
</option>
|
||||
))}
|
||||
</ComponentSelect>
|
||||
<ComponentSelect
|
||||
label="字体样式"
|
||||
value={component.font_style}
|
||||
@@ -254,6 +269,10 @@ function fontSizingVariant(sizing: FontSizing): string {
|
||||
return 'Fixed' in sizing ? 'Fixed' : 'BestFit';
|
||||
}
|
||||
|
||||
function fontSourceValue(font: TextEditorProps['component']['font']): string {
|
||||
return typeof font === 'string' ? 'SystemFont' : font.Bound;
|
||||
}
|
||||
|
||||
function defaultFontSizing(kind: string): FontSizing {
|
||||
return kind === 'BestFit' ? { BestFit: { min: 8, max: 32 } } : { Fixed: 14 };
|
||||
}
|
||||
|
||||
+6
-7
@@ -6,6 +6,7 @@ import type {
|
||||
TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
import { SpriteImagePreview } from '../../../../features/ui-editor/components/SpriteImagePreview';
|
||||
import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole';
|
||||
import { uiEditorPrivateFontFamily } from '../../../../features/ui-editor/useUiEditorFontFaces';
|
||||
@@ -524,13 +525,11 @@ function SpriteInspector({
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid aspect-square place-items-center overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={sprite.metadata.name}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<SpriteImagePreview
|
||||
src={previewUrl}
|
||||
alt={sprite.metadata.name}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<InspectorInput
|
||||
label="完整名称"
|
||||
|
||||
@@ -16,9 +16,9 @@ UI Editor Inspector 的全局只读状态唯一来源是 `controller.editor.isLo
|
||||
|
||||
UI Editor 的 `State.font_assets` 正式承载项目字体面资源;`FontAsset` 包含项目资产 ID、受控项目相对路径、内容 SHA-256,以及由 Rust 解析的 family、face、weight、italic、格式和源文件名。`TextComponent.font` 直接绑定一个具体字体面;原 `font_style` 是与字体面元数据重复且可能矛盾的状态,直接从 Rust 权威类型和生成 TypeScript 类型中删除,不保留会话态兼容层。UI Editor 整体 State 仍只属于当前桌面会话,不新增持久化合同。
|
||||
|
||||
字体资源发现与 Sprite 保持同一项目资产语义:字体 importer 打开后读取 manifest 与项目文件列表,只展示已登记且具有字体 MIME 或受支持字体扩展名的项目资产;不扫描或接纳未登记文件,不展示云端素材库。从电脑导入使用 Tauri 系统文件选择器,Rust 整批读取普通文件,拒绝符号链接、集合字体、超过 `8 MiB` 的单文件、超过 `64` 个字体面或 `32 MiB` 项目总量,完成真实签名、字体表、名称与 weight/style 解析后复制到 `assets/fonts/` 并登记 manifest。内容相同的字体复用已登记资源;Sprite 与 Font 的 State 批量加入都采用幂等合并:相同 ID 且完整资源相等时跳过,同 ID 数据冲突时整批失败。删除只移除会话 State 资源并清空相应 `Image.target_graphic` 或 `Text.font` 引用,不删除项目文件或 manifest 条目。
|
||||
字体资源发现与 Sprite 保持同一项目资产语义:两种 importer 在 FileManager Home 下都从唯一的 `本地项目` 根进入,再按各自 `typeFilter` 展示已登记资源;不扫描或接纳未登记文件,字体不展示云端素材库。从电脑导入使用 Tauri 系统文件选择器,Rust 整批读取普通文件,拒绝符号链接、集合字体、超过 `8 MiB` 的单文件、超过 `64` 个字体面或 `32 MiB` 项目总量,完成真实签名、字体表、名称与 weight/style 解析后复制到 `assets/fonts/` 并登记 manifest。内容相同的字体复用已登记资源;Sprite 与 Font 的 State 批量加入都采用幂等合并:相同 ID 且完整资源相等时跳过,同 ID 数据冲突时整批失败。删除只移除会话 State 资源并清空相应 `Image.target_graphic` 或 `Text.font` 引用,不删除项目文件或 manifest 条目。
|
||||
|
||||
候选格式为 TTF、OTF、WOFF 和 WOFF2,但 Rust 安全解析是导入硬门;当前解析依赖不能完整解析的压缩 Web Font 必须拒绝,不能把浏览器可能加载当作验证成功。已登记字体字节只能经字体专用 Tauri 命令读取;命令重新核对 manifest 的 asset ID / 相对路径、普通文件、大小、字体结构与摘要。Importer 预览也只读取同一受 manifest 约束的字体字节,再临时加载 `FontFace` 显示选中文件名;切换选择或关闭时立即卸载。前端以 `FontAssetId` 派生私有 CSS family,创建 Blob URL 和 `FontFace`,加载成功后加入当前 `document.fonts`,资源变更或卸载时删除 FontFace 并回收 Blob URL。同名 family 不共享浏览器注册名。WebView 加载失败或字体缺少当前文本字形时不阻塞后续阶段,Inspector 显示非阻断提示并回退系统字体;悬空字体 ID 继续由 prerequisite 阻止。`BestFit` 保持现有取最大字号的近似,不在本次字体闭环中扩展为测量算法。
|
||||
候选格式为 TTF、OTF、WOFF 和 WOFF2,但 Rust 安全解析是导入硬门;当前解析依赖不能完整解析的压缩 Web Font 必须拒绝,不能把浏览器可能加载当作验证成功。已登记字体字节只能经字体专用 Tauri 命令读取;命令重新核对 manifest 的 asset ID / 相对路径、普通文件、大小、字体结构与摘要。Importer 预览也只读取同一受 manifest 约束的字体字节,再临时加载 `FontFace` 显示黑色的中英文、数字和标点多字号样张;切换选择或关闭时立即卸载。前端以 `FontAssetId` 派生私有 CSS family,创建 Blob URL 和 `FontFace`,加载成功后加入当前 `document.fonts`,资源变更或卸载时删除 FontFace 并回收 Blob URL。同名 family 不共享浏览器注册名。WebView 加载失败或字体缺少当前文本字形时不阻塞后续阶段,Inspector 显示非阻断提示并回退系统字体;悬空字体 ID 继续由 prerequisite 阻止。`BestFit` 保持现有取最大字号的近似,不在本次字体闭环中扩展为测量算法。
|
||||
|
||||
## 2026-08-18 UI Editor 图片 / 字体通用 AssetImporter 契约
|
||||
|
||||
|
||||
Reference in New Issue
Block a user