修复导入器字体预览与根目录

修正文件管理器 Home 根路径并恢复本地来源入口

为字体候选增加受 manifest 约束的临时 FontFace 预览

同步字体预览读取约束
This commit is contained in:
2026-08-18 18:28:23 +08:00
parent 0a0c8eb75c
commit b21b252429
4 changed files with 80 additions and 5 deletions
@@ -1996,6 +1996,19 @@ 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,6 +2301,7 @@ 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,
@@ -79,12 +79,16 @@ export function AssetImporter({
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;
@@ -221,15 +225,56 @@ export function AssetImporter({
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;
@@ -255,7 +300,16 @@ export function AssetImporter({
})
.then((value) => setPreview(value.dataUrl))
.catch(() => setPreview(null));
}, [projectPath, selected]);
}, [projectPath, selected, settings.local.localPolicy.destination]);
useEffect(
() => () => {
if (previewFontFaceRef.current) {
document.fonts?.delete(previewFontFaceRef.current);
}
},
[],
);
const pickLocal = async () => {
setError(null);
@@ -361,7 +415,7 @@ export function AssetImporter({
void pickLocal();
};
const handleFolderChange = (path: string) => {
const nextPath = path || ROOT_PATH;
const nextPath = path === '/' ? ROOT_PATH : path;
currentPathRef.current = nextPath;
setCurrentPath(nextPath);
if (
@@ -412,7 +466,7 @@ export function AssetImporter({
) : null}
<div className="min-h-0 flex-1 p-3">
<FileManager
key={`${settings.ariaLabel}:${open ? 'open' : 'closed'}`}
key={`${settings.ariaLabel}:${currentPath}`}
style={{ minHeight: 0, height: '100%', width: '100%' }}
files={files}
layout="grid"
@@ -452,7 +506,14 @@ export function AssetImporter({
className="max-h-72 max-w-full object-contain"
/>
) : (
<span className="text-xs text-(--platform-text-soft)">
<span
className="text-xs text-(--platform-text-soft)"
style={
fontPreviewFamily
? { fontFamily: fontPreviewFamily, fontSize: '1rem' }
: undefined
}
>
{selected.at(-1)?.name ?? '选择一个文件'}
</span>
)}
@@ -18,7 +18,7 @@ UI Editor 的 `State.font_assets` 正式承载项目字体面资源;`FontAsset
字体资源发现与 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 条目。
候选格式为 TTF、OTF、WOFF 和 WOFF2,但 Rust 安全解析是导入硬门;当前解析依赖不能完整解析的压缩 Web Font 必须拒绝,不能把浏览器可能加载当作验证成功。已登记字体字节只能经字体专用 Tauri 命令读取;命令重新核对 manifest 的 asset ID / 相对路径、普通文件、大小、字体结构与摘要。前端以 `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 契约