支持 UI 编辑器项目字体导入

新增 Rust 字体校验、复制登记与安全读取链路

新增项目字体面板、Text 字体绑定和预览回退

清理废弃 UI 设计持久化入口并补齐测试文档
This commit is contained in:
2026-08-17 16:43:00 +08:00
parent 36d88304ca
commit 80ff184605
30 changed files with 1452 additions and 263 deletions
+7
View File
@@ -1682,6 +1682,7 @@ dependencies = [
"tempfile",
"tokio",
"ts-rs",
"ttf-parser",
"typed_floats",
"unicode-normalization",
"url",
@@ -5973,6 +5974,12 @@ dependencies = [
"termcolor",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.28.0"
@@ -49,6 +49,7 @@ tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
tauri-plugin-opener = "2"
tempfile = "3"
ttf-parser = "0.25.1"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
url = "2"
unicode-normalization = "0.1"
@@ -1,4 +1,9 @@
use super::*;
use crate::ui_editor::resource::font::FontAsset;
const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024;
const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024;
const UI_EDITOR_FONT_MAX_COUNT: usize = 64;
#[tauri::command]
pub(crate) fn init_local_game_project(
@@ -1531,6 +1536,422 @@ pub(crate) fn import_ui_editor_local_files(
Ok(LocalImportResult { assets })
}
fn is_ui_editor_font_manifest_asset(asset: &GameCreationAppAssetManifestEntry) -> bool {
let media_type = asset.media_type.trim().to_ascii_lowercase();
let extension = Path::new(&asset.local_path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
matches!(
media_type.as_str(),
"font/ttf" | "font/otf" | "font/woff" | "font/woff2"
) || matches!(extension.as_str(), "ttf" | "otf" | "woff" | "woff2")
}
fn read_registered_ui_editor_font(
root: &Path,
asset: &GameCreationAppAssetManifestEntry,
) -> Result<(FontAsset, Vec<u8>), String> {
if !is_ui_editor_font_manifest_asset(asset) {
return Err("项目资产不是受支持的字体候选".to_string());
}
let target = resolve_local_project_path(root, &asset.local_path)?;
let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("项目字体必须是普通文件".to_string());
}
if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE {
return Err("项目字体超过 8 MiB 限制".to_string());
}
let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?;
let source_file_name = target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("font")
.to_string();
let mut font = FontAsset::from_verified_bytes(
asset.id.clone(),
asset.local_path.clone(),
source_file_name,
&bytes,
)?;
if let Some(copied_name) = target.file_name().and_then(|value| value.to_str()) {
let hash_prefix = format!("{}-", font.content_sha256);
if let Some(original_name) = copied_name.strip_prefix(&hash_prefix) {
if !original_name.is_empty() {
font.metadata.source_file_name = original_name.to_string();
}
}
}
Ok((font, bytes))
}
fn find_registered_ui_editor_font<'a>(
manifest: &'a GameCreationAppManifest,
asset_id: &str,
relative_path: &str,
) -> Result<&'a GameCreationAppAssetManifestEntry, String> {
manifest
.assets
.iter()
.find(|asset| asset.id == asset_id && asset.local_path == relative_path)
.ok_or_else(|| "字体不是当前项目已登记资产".to_string())
}
#[tauri::command]
pub(crate) fn prepare_ui_editor_project_fonts(
project_path: String,
assets: Vec<ImportedAsset>,
) -> Result<Vec<FontAsset>, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
if assets.len() > UI_EDITOR_FONT_MAX_COUNT {
return Err("一次最多选择 64 个字体面".to_string());
}
let manifest = read_existing_manifest_for_project(root)?;
let mut fonts = Vec::with_capacity(assets.len());
for selected in assets {
let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?;
fonts.push(read_registered_ui_editor_font(root, asset)?.0);
}
Ok(fonts)
}
#[tauri::command]
pub(crate) fn import_ui_editor_local_fonts(
project_path: String,
source_paths: Vec<String>,
) -> Result<LocalImportResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.upload")?;
let _lock = acquire_project_write_lock(root, "asset.upload")?;
if source_paths.len() > UI_EDITOR_FONT_MAX_COUNT {
return Err("一次最多选择 64 个字体面".to_string());
}
let mut inputs = Vec::new();
let mut input_hashes = std::collections::BTreeSet::new();
for source in source_paths {
let path = Path::new(source.trim());
let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("只能导入普通字体文件".to_string());
}
if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE {
return Err("字体超过 8 MiB 限制".to_string());
}
let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?;
let source_file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("font")
.to_string();
let validated = FontAsset::from_verified_bytes(
"font-validation",
"assets/fonts/validation.ttf",
source_file_name.clone(),
&bytes,
)?;
if input_hashes.insert(validated.content_sha256.clone()) {
inputs.push((source_file_name, bytes, validated));
}
}
let manifest = read_existing_manifest_for_project(root)?;
let mut existing_by_hash = std::collections::BTreeMap::new();
let mut existing_total_size = 0u64;
let mut existing_count = 0usize;
for asset in manifest
.assets
.iter()
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
{
let Ok((font, bytes)) = read_registered_ui_editor_font(root, asset) else {
continue;
};
existing_total_size = existing_total_size.saturating_add(bytes.len() as u64);
existing_count += 1;
existing_by_hash
.entry(font.content_sha256.clone())
.or_insert(font);
}
let new_inputs = inputs
.iter()
.filter(|(_, _, font)| !existing_by_hash.contains_key(&font.content_sha256))
.collect::<Vec<_>>();
let new_total_size = new_inputs.iter().fold(0u64, |total, (_, bytes, _)| {
total.saturating_add(bytes.len() as u64)
});
if existing_count.saturating_add(new_inputs.len()) > UI_EDITOR_FONT_MAX_COUNT {
return Err("项目字体面最多 64 个".to_string());
}
if existing_total_size.saturating_add(new_total_size) > UI_EDITOR_FONT_MAX_TOTAL_SIZE {
return Err("项目字体总量超过 32 MiB 限制".to_string());
}
if !new_inputs.is_empty() {
advance_agent_runtime_project_revision_locked(root)?;
let font_root = root.join("assets/fonts");
fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?;
}
let mut result = Vec::with_capacity(inputs.len());
for (source_file_name, bytes, validated) in inputs {
if let Some(existing) = existing_by_hash.get(&validated.content_sha256) {
result.push(existing.clone());
continue;
}
let safe_stem = sanitize_file_name(
Path::new(&source_file_name)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("font"),
);
let relative_path = format!(
"assets/fonts/{}-{safe_stem}.{}",
validated.content_sha256,
validated.metadata.format.extension()
);
let target = resolve_local_project_path(root, &relative_path)?;
fs::write(&target, &bytes).map_err(|_| "写入项目字体失败".to_string())?;
let registered = register_local_asset_entry(
root,
&relative_path,
"font",
validated.metadata.format.media_type(),
"font",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)?;
let font = FontAsset::from_verified_bytes(
registered.id,
registered.local_path,
source_file_name,
&bytes,
)?;
existing_by_hash.insert(font.content_sha256.clone(), font.clone());
result.push(font);
}
Ok(LocalImportResult {
assets: result
.into_iter()
.map(|font| ImportedAsset {
id: font.asset_id.as_str().to_string(),
local_path: font.path,
asset_kind: Some("font".to_string()),
})
.collect(),
})
}
fn read_checked_ui_editor_font(
project_path: &str,
asset_id: &str,
relative_path: &str,
expected_sha256: &str,
) -> Result<(FontAsset, Vec<u8>), 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, relative_path)?;
let (font, bytes) = read_registered_ui_editor_font(root, asset)?;
if font.content_sha256 != expected_sha256 {
return Err("字体文件内容与资源摘要不一致".to_string());
}
Ok((font, bytes))
}
#[tauri::command]
pub(crate) fn read_ui_editor_font_bytes(
project_path: String,
asset_id: String,
relative_path: String,
expected_sha256: String,
) -> Result<tauri::ipc::Response, String> {
read_checked_ui_editor_font(
&project_path,
asset_id.trim(),
relative_path.trim(),
expected_sha256.trim(),
)
.map(|(_, bytes)| tauri::ipc::Response::new(bytes))
}
#[tauri::command]
pub(crate) fn check_ui_editor_font_glyph_coverage(
project_path: String,
asset_id: String,
relative_path: String,
expected_sha256: String,
content: String,
) -> Result<bool, String> {
let (font, bytes) = read_checked_ui_editor_font(
&project_path,
asset_id.trim(),
relative_path.trim(),
expected_sha256.trim(),
)?;
font.has_missing_glyphs(&bytes, &content)
}
#[cfg(test)]
mod ui_editor_font_tests {
use super::*;
fn fixture_font_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf")
}
#[test]
fn registered_font_reader_requires_manifest_identity_and_verified_bytes() {
let project = tempfile::tempdir().expect("project tempdir");
let root = project.path();
init_local_game_project_at(root, "font-project", "Font Project")
.expect("initialize project");
let relative_path = "assets/fonts/fusion-pixel.ttf";
let target = root.join(relative_path);
fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent");
fs::copy(fixture_font_path(), &target).expect("copy font fixture");
let registered = register_local_asset_entry(
root,
relative_path,
"font",
"font/ttf",
"font",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
.expect("register font");
let manifest = read_existing_manifest_for_project(root).expect("read manifest");
let entry =
find_registered_ui_editor_font(&manifest, &registered.id, &registered.local_path)
.expect("find registered font");
let (font, bytes) = read_registered_ui_editor_font(root, entry).expect("read font");
assert_eq!(font.asset_id.as_str(), registered.id);
assert_eq!(font.path, relative_path);
assert_eq!(font.content_sha256.len(), 64);
assert!(!bytes.is_empty());
assert!(find_registered_ui_editor_font(&manifest, "missing", relative_path).is_err());
}
#[test]
fn local_font_import_copies_registers_and_reuses_identical_content() {
let project = tempfile::tempdir().expect("project tempdir");
let root = project.path();
init_local_game_project_at(root, "font-project", "Font Project")
.expect("initialize project");
let source_path = fixture_font_path().to_string_lossy().into_owned();
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.expect("read initial revision")
.revision;
let first = import_ui_editor_local_fonts(
root.to_string_lossy().into_owned(),
vec![source_path.clone()],
)
.expect("import font");
assert_eq!(first.assets.len(), 1);
let imported = &first.assets[0];
assert!(imported.local_path.starts_with("assets/fonts/"));
assert!(imported.local_path.ends_with("-fusion-pixel.ttf"));
assert!(root.join(&imported.local_path).is_file());
let manifest = read_existing_manifest_for_project(root).expect("read manifest");
assert_eq!(
manifest
.assets
.iter()
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
.count(),
1
);
assert!(manifest
.assets
.iter()
.any(|asset| { asset.id == imported.id && asset.local_path == imported.local_path }));
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
.expect("read imported revision")
.revision;
assert_eq!(revision_after_first, revision_before + 1);
let second =
import_ui_editor_local_fonts(root.to_string_lossy().into_owned(), vec![source_path])
.expect("reimport identical font");
assert_eq!(second, first);
let manifest = read_existing_manifest_for_project(root).expect("read reused manifest");
assert_eq!(
manifest
.assets
.iter()
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
.count(),
1
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.expect("read reused revision")
.revision,
revision_after_first
);
}
#[cfg(unix)]
#[test]
fn registered_font_reader_rejects_symlink() {
use std::os::unix::fs::symlink;
let project = tempfile::tempdir().expect("project tempdir");
let root = project.path();
let relative_path = "assets/fonts/linked.ttf";
let target = root.join(relative_path);
fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent");
symlink(fixture_font_path(), &target).expect("create font symlink");
let entry = GameCreationAppAssetManifestEntry {
id: "font-linked".to_string(),
kind: "font".to_string(),
media_type: "font/ttf".to_string(),
local_path: relative_path.to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
assert_eq!(
read_registered_ui_editor_font(root, &entry).expect_err("reject symlink"),
"项目文件路径不能包含符号链接"
);
}
}
#[tauri::command]
pub(crate) async fn import_ui_editor_remote_assets(
project_path: String,
@@ -950,14 +950,6 @@ struct UploadLocalAssetResult {
manifest_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateUiDesignResourceResult {
asset: UploadLocalAssetResult,
manifest: GameCreationAppManifest,
committed_project_revision: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportCanvasExportResult {
@@ -984,7 +976,7 @@ struct LocalImportResult {
type RemoteImportResult = LocalImportResult;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportedAsset {
id: String,
@@ -2287,7 +2279,6 @@ fn main() {
read_game_creator_mcp_catalog,
upload_local_asset,
register_local_asset,
create_ui_design_resource,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -2299,6 +2290,10 @@ fn main() {
import_canvas_export,
sync_canvas_project_assets,
import_ui_editor_local_files,
import_ui_editor_local_fonts,
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
import_ui_editor_remote_assets,
suggest_ui_design_semantic,
recognize_ui,
@@ -1,10 +1,213 @@
use crate::ui_editor::utils::FontAssetId;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use ts_rs::TS;
// TODO
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum FontFormat {
TrueType,
OpenType,
Woff,
Woff2,
}
impl FontFormat {
pub fn media_type(self) -> &'static str {
match self {
Self::TrueType => "font/ttf",
Self::OpenType => "font/otf",
Self::Woff => "font/woff",
Self::Woff2 => "font/woff2",
}
}
pub fn extension(self) -> &'static str {
match self {
Self::TrueType => "ttf",
Self::OpenType => "otf",
Self::Woff => "woff",
Self::Woff2 => "woff2",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct FontAssetMetadata {
pub(crate) family_name: String,
pub(crate) face_name: String,
pub(crate) weight: u16,
pub(crate) italic: bool,
pub(crate) format: FontFormat,
pub(crate) source_file_name: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct FontAsset {
asset_id: FontAssetId,
pub(crate) asset_id: FontAssetId,
pub(crate) metadata: FontAssetMetadata,
pub(crate) path: String,
pub(crate) content_sha256: String,
}
impl FontAsset {
pub(crate) fn from_verified_bytes(
asset_id: impl Into<String>,
path: impl Into<String>,
source_file_name: impl Into<String>,
bytes: &[u8],
) -> Result<Self, String> {
let asset_id =
FontAssetId::new(asset_id.into()).map_err(|_| "字体资源 ID 无效".to_string())?;
let path = path.into();
if path.trim().is_empty() || path.trim() != path {
return Err("字体资源路径无效".to_string());
}
let source_file_name = source_file_name.into();
let format = detect_font_format(bytes)?;
let face = ttf_parser::Face::parse(bytes, 0)
.map_err(|_| format!("当前 Rust 解析器无法安全解析 {} 字体", format.extension()))?;
if ttf_parser::fonts_in_collection(bytes).is_some() {
return Err("暂不支持字体集合文件".to_string());
}
let family_name = font_name(
&face,
&[
ttf_parser::name_id::TYPOGRAPHIC_FAMILY,
ttf_parser::name_id::FAMILY,
],
)
.ok_or_else(|| "字体缺少可用的 family 名称".to_string())?;
let face_name = font_name(
&face,
&[
ttf_parser::name_id::TYPOGRAPHIC_SUBFAMILY,
ttf_parser::name_id::SUBFAMILY,
],
)
.unwrap_or_else(|| "Regular".to_string());
let content_sha256 = format!("{:x}", Sha256::digest(bytes));
Ok(Self {
asset_id,
metadata: FontAssetMetadata {
family_name,
face_name,
weight: face.weight().to_number().clamp(1, 1000),
italic: face.is_italic() || face.is_oblique(),
format,
source_file_name,
},
path,
content_sha256,
})
}
pub(crate) fn has_missing_glyphs(&self, bytes: &[u8], content: &str) -> Result<bool, String> {
let digest = format!("{:x}", Sha256::digest(bytes));
if digest != self.content_sha256 {
return Err("字体文件内容与资源摘要不一致".to_string());
}
let face =
ttf_parser::Face::parse(bytes, 0).map_err(|_| "字体文件无法安全解析".to_string())?;
Ok(content.chars().any(|character| {
!character.is_whitespace()
&& !character.is_control()
&& !matches!(character, '\u{fe0e}' | '\u{fe0f}')
&& face.glyph_index(character).is_none()
}))
}
}
fn detect_font_format(bytes: &[u8]) -> Result<FontFormat, String> {
if bytes.starts_with(b"wOFF") {
return Ok(FontFormat::Woff);
}
if bytes.starts_with(b"wOF2") {
return Ok(FontFormat::Woff2);
}
if bytes.starts_with(b"OTTO") {
return Ok(FontFormat::OpenType);
}
if bytes.starts_with(&[0x00, 0x01, 0x00, 0x00]) || bytes.starts_with(b"true") {
return Ok(FontFormat::TrueType);
}
if bytes.starts_with(b"ttcf") {
return Err("暂不支持字体集合文件".to_string());
}
Err("文件不是受支持的 TTF/OTF/WOFF/WOFF2 字体".to_string())
}
fn font_name(face: &ttf_parser::Face<'_>, ids: &[u16]) -> Option<String> {
ids.iter().find_map(|id| {
face.names()
.into_iter()
.filter(|name| name.name_id == *id)
.find_map(|name| name.to_string())
.map(|name| name.trim().to_string())
.filter(|name| {
!name.is_empty()
&& name.chars().count() <= 200
&& !name.chars().any(char::is_control)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_font() -> Vec<u8> {
std::fs::read(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../public/fusion-pixel.ttf"),
)
.expect("read checked-in font fixture")
}
#[test]
fn verified_font_asset_extracts_face_metadata_and_digest() {
let bytes = fixture_font();
let font = FontAsset::from_verified_bytes(
"font-asset",
"assets/fonts/fusion-pixel.ttf",
"fusion-pixel.ttf",
&bytes,
)
.expect("parse font fixture");
assert!(!font.metadata.family_name.is_empty());
assert!(!font.metadata.face_name.is_empty());
assert_eq!(font.metadata.format, FontFormat::TrueType);
assert_eq!(font.content_sha256.len(), 64);
assert!(!font
.has_missing_glyphs(&bytes, "ABC 123")
.expect("check common glyphs"));
assert!(font
.has_missing_glyphs(&bytes, "\u{10ffff}")
.expect("check missing glyph"));
}
#[test]
fn verified_font_asset_rejects_collections_and_unparseable_web_fonts() {
assert_eq!(
FontAsset::from_verified_bytes(
"font-asset",
"assets/fonts/font.ttc",
"font.ttc",
b"ttcf\0\x01\0\0",
)
.expect_err("reject collection"),
"暂不支持字体集合文件"
);
assert!(FontAsset::from_verified_bytes(
"font-asset",
"assets/fonts/font.woff2",
"font.woff2",
b"wOF2invalid",
)
.expect_err("reject unparseable web font")
.contains("无法安全解析"));
}
}
@@ -18,6 +18,7 @@ import {
} from '../../services/projectResourcePreviewTransport';
import { ThemedModal } from '../modal/ThemedModal';
import {
type AssetImporterMode,
buildProjectFiles,
buildRemoteFolderFiles,
buildRootFiles,
@@ -40,6 +41,7 @@ export type UiEditorImageImporterProps = {
maxItems: number;
maxFileSize: number;
acceptedMediaTypes: readonly string[];
mode?: AssetImporterMode;
};
type LocalProjectFile = { path: string };
@@ -67,6 +69,7 @@ export function AssetImporter({
maxItems,
maxFileSize,
acceptedMediaTypes,
mode = 'image',
}: UiEditorImageImporterProps) {
const [files, setFiles] = useState<ManagerFile[]>([]);
const [selected, setSelected] = useState<ManagerFile[]>([]);
@@ -115,25 +118,34 @@ export function AssetImporter({
filesFromDisk
.map((file) => byPath.get(file.path))
.filter(Boolean) as ManifestAsset[],
mode,
);
}, [projectPath]);
}, [mode, projectPath]);
const replaceProjectFiles = useCallback((projectFiles: ManagerFile[]) => {
setFiles((current) => [
...buildRootFiles(),
...projectFiles,
...current.filter((file) => file.source === 'remote'),
]);
}, []);
const replaceProjectFiles = useCallback(
(projectFiles: ManagerFile[]) => {
setFiles((current) => [
...buildRootFiles(mode),
...projectFiles,
...(mode === 'image'
? current.filter((file) => file.source === 'remote')
: []),
]);
},
[mode],
);
const replaceRemoteFiles = useCallback((remoteFiles: ManagerFile[]) => {
setFiles((current) => [
...buildRootFiles(),
...current.filter((file) => file.source === 'local'),
...remoteFiles,
]);
remoteLoadedRef.current = true;
}, []);
const replaceRemoteFiles = useCallback(
(remoteFiles: ManagerFile[]) => {
setFiles((current) => [
...buildRootFiles(mode),
...current.filter((file) => file.source === 'local'),
...remoteFiles,
]);
remoteLoadedRef.current = true;
},
[mode],
);
const loadRemote = useCallback(async () => {
const library = await loadEditorAssetLibrary();
@@ -150,7 +162,8 @@ export function AssetImporter({
const shouldRefreshProject =
path === ROOT_PATH || path.startsWith(PROJECT_ASSETS_PATH);
const shouldRefreshRemote =
path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH);
mode === 'image' &&
(path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH));
const projectResult = shouldRefreshProject
? await Promise.allSettled([loadLocal()]).then(([result]) => result)
: null;
@@ -188,7 +201,7 @@ export function AssetImporter({
setLoading(false);
}
},
[loadLocal, loadRemote, replaceProjectFiles],
[loadLocal, loadRemote, mode, replaceProjectFiles],
);
useEffect(() => {
@@ -196,14 +209,18 @@ export function AssetImporter({
currentPathRef.current = ROOT_PATH;
remoteLoadedRef.current = false;
setCurrentPath(ROOT_PATH);
setFiles(buildRootFiles());
setFiles(buildRootFiles(mode));
setSelected([]);
setPreview(null);
void refresh(ROOT_PATH);
}, [open, refresh]);
}, [mode, open, refresh]);
useEffect(() => {
const item = selected.at(-1);
if (mode === 'font') {
setPreview(null);
return;
}
if (!item || item.isDirectory) {
setPreview(null);
return;
@@ -233,28 +250,49 @@ export function AssetImporter({
})
.then((value) => setPreview(value.dataUrl))
.catch(() => setPreview(null));
}, [projectPath, selected]);
}, [mode, projectPath, selected]);
const pickLocal = async () => {
setError(null);
setLoading(true);
try {
const paths = await openNativeFileDialog({
title: '选择图片素材',
title: mode === 'font' ? '选择字体文件' : '选择图片素材',
multiple: true,
directory: false,
filters: [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] },
],
filters:
mode === 'font'
? [
{
name: 'Fonts',
extensions: ['ttf', 'otf', 'woff', 'woff2'],
},
]
: [
{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : [];
if (!sourcePaths.length) return;
if (sourcePaths.length > maxItems)
throw new Error(`最多选择 ${maxItems} 张图片`);
const result = await invoke<{ assets: ImportAssetResponse[] }>(
'import_ui_editor_local_files',
{ projectPath, sourcePaths, maxFileSize },
);
throw new Error(
mode === 'font'
? `最多选择 ${maxItems} 个字体面`
: `最多选择 ${maxItems} 张图片`,
);
const result =
mode === 'font'
? await invoke<{ assets: ImportAssetResponse[] }>(
'import_ui_editor_local_fonts',
{ projectPath, sourcePaths },
)
: await invoke<{ assets: ImportAssetResponse[] }>(
'import_ui_editor_local_files',
{ projectPath, sourcePaths, maxFileSize },
);
onImport(
result.assets.map((asset) => ({
id: asset.id,
@@ -331,7 +369,11 @@ export function AssetImporter({
const nextPath = path || ROOT_PATH;
currentPathRef.current = nextPath;
setCurrentPath(nextPath);
if (nextPath.startsWith(REMOTE_ASSETS_PATH) && !remoteLoadedRef.current) {
if (
mode === 'image' &&
nextPath.startsWith(REMOTE_ASSETS_PATH) &&
!remoteLoadedRef.current
) {
setLoading(true);
setError(null);
void loadRemote()
@@ -346,12 +388,12 @@ export function AssetImporter({
<ThemedModal
open={open}
onClose={onClose}
ariaLabel="导入图片素材"
ariaLabel={mode === 'font' ? '导入字体' : '导入图片素材'}
panelClassName="flex h-[min(760px,92dvh)] w-[min(1100px,96vw)] min-w-0 flex-col overflow-hidden rounded-2xl shadow-2xl"
>
<header className="relative flex h-14 shrink-0 items-center justify-center border-b border-(--platform-subpanel-border) px-14">
<h2 className="m-0 text-center text-sm font-semibold leading-5">
{mode === 'font' ? '导入字体' : '导入图片素材'}
</h2>
<button
type="button"
@@ -403,9 +445,15 @@ export function AssetImporter({
</div>
</div>
<aside className="flex w-72 shrink-0 flex-col border-l border-(--platform-subpanel-border) p-4">
<h3 className="text-xs font-semibold"></h3>
<h3 className="text-xs font-semibold">
{mode === 'font' ? '字体文件' : '预览'}
</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 ? (
{mode === 'font' ? (
<span className="px-4 text-center text-xs text-(--platform-text-soft)">
{selected.at(-1)?.name ?? '选择一个字体文件'}
</span>
) : preview ? (
<img
src={preview}
alt="选中图片预览"
@@ -35,14 +35,20 @@ type RemoteLibrary = { folders?: RemoteFolder[]; assets?: RemoteAsset[] };
export const PROJECT_ASSETS_PATH = '/本地项目素材';
export const REMOTE_ASSETS_PATH = '/云端素材库';
export type AssetImporterMode = 'font' | 'image';
function isImageAssetKind(assetKind: unknown) {
const kind = typeof assetKind === 'string' ? assetKind.trim().toLowerCase() : '';
const kind =
typeof assetKind === 'string' ? assetKind.trim().toLowerCase() : '';
if (!kind) return true;
return !(
kind === 'video' || kind === 'audio' || kind === 'sound-effect' ||
kind === 'background-music' || kind === 'character-animation' ||
kind.startsWith('video-') || kind.startsWith('audio-') ||
kind === 'video' ||
kind === 'audio' ||
kind === 'sound-effect' ||
kind === 'background-music' ||
kind === 'character-animation' ||
kind.startsWith('video-') ||
kind.startsWith('audio-') ||
kind.startsWith('character-animation')
);
}
@@ -61,18 +67,39 @@ function safeManagerName(value: unknown, fallback: string) {
return (normalized || fallback).replaceAll('/', '').replaceAll('\\', '');
}
export function buildProjectFiles(assets: ManifestAsset[]): ManagerFile[] {
function isFontManifestAsset(asset: ManifestAsset) {
const mediaType = asset.mediaType.trim().toLowerCase();
const path = imagePath(asset.localPath).toLowerCase();
return (
['font/ttf', 'font/otf', 'font/woff', 'font/woff2'].includes(mediaType) ||
/\.(ttf|otf|woff2?)$/u.test(path)
);
}
export function buildProjectFiles(
assets: ManifestAsset[],
mode: AssetImporterMode = 'image',
): ManagerFile[] {
const result: ManagerFile[] = [];
const seen = new Set<string>();
for (const asset of assets) {
const path = imagePath(asset.localPath);
if (!path.startsWith('assets/') || !/^assets\/(.*\.(png|jpe?g|webp))$/i.test(path)) continue;
const supported =
mode === 'font'
? isFontManifestAsset(asset)
: /^assets\/(.*\.(png|jpe?g|webp))$/iu.test(path);
if (!path.startsWith('assets/') || !supported) continue;
const parts = path.split('/');
for (let index = 1; index < parts.length - 1; index += 1) {
const folderPath = `${PROJECT_ASSETS_PATH}/${parts.slice(1, index + 1).join('/')}`;
if (seen.has(folderPath)) continue;
seen.add(folderPath);
result.push({ name: parts[index] ?? folderPath, isDirectory: true, path: folderPath, source: 'local' });
result.push({
name: parts[index] ?? folderPath,
isDirectory: true,
path: folderPath,
source: 'local',
});
}
result.push({
name: parts.at(-1) ?? path,
@@ -86,34 +113,57 @@ export function buildProjectFiles(assets: ManifestAsset[]): ManagerFile[] {
}
export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
const root = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const root =
payload && typeof payload === 'object'
? (payload as Record<string, unknown>)
: {};
const candidate = root.library ?? root.data ?? payload;
const library: RemoteLibrary = candidate && typeof candidate === 'object' ? (candidate as RemoteLibrary) : {};
const library: RemoteLibrary =
candidate && typeof candidate === 'object'
? (candidate as RemoteLibrary)
: {};
const folders = Array.isArray(library.folders) ? library.folders : [];
const assets = Array.isArray(library.assets) ? library.assets : [];
const result: ManagerFile[] = [];
const folderIds = new Set(folders.map((folder) => folder.folderId));
const normalizedFolders = [...folders];
if (assets.some((asset) => !folderIds.has(asset.folderId))) normalizedFolders.push({ folderId: '__uncategorized__', label: '未分类' });
if (assets.some((asset) => !folderIds.has(asset.folderId)))
normalizedFolders.push({ folderId: '__uncategorized__', label: '未分类' });
const usedFolderNames = new Set<string>();
for (const folder of normalizedFolders) {
const baseFolderName = safeManagerName(folder.label, folder.folderId || '未分类');
const baseFolderName = safeManagerName(
folder.label,
folder.folderId || '未分类',
);
const folderName = usedFolderNames.has(baseFolderName)
? `${baseFolderName} (${safeManagerName(folder.folderId, String(usedFolderNames.size + 1))})`
: baseFolderName;
usedFolderNames.add(folderName);
const folderPath = `${REMOTE_ASSETS_PATH}/${folderName}`;
result.push({ name: folderName, isDirectory: true, path: folderPath, source: 'remote' });
const folderAssets = assets.filter((item) => folder.folderId === '__uncategorized__' ? !folderIds.has(item.folderId) : item.folderId === folder.folderId);
result.push({
name: folderName,
isDirectory: true,
path: folderPath,
source: 'remote',
});
const folderAssets = assets.filter((item) =>
folder.folderId === '__uncategorized__'
? !folderIds.has(item.folderId)
: item.folderId === folder.folderId,
);
const usedNames = new Set<string>();
for (const asset of folderAssets) {
if (!isImageAssetKind(asset.assetKind)) continue;
const baseName = safeManagerName(asset.label, asset.assetId || '图片素材');
const baseName = safeManagerName(
asset.label,
asset.assetId || '图片素材',
);
const fileName = usedNames.has(baseName)
? `${baseName} (${safeManagerName(asset.assetId, String(usedNames.size + 1))})`
: baseName;
usedNames.add(fileName);
const localPath = asset.objectKey || asset.imageSrc || asset.assetId || fileName;
const localPath =
asset.objectKey || asset.imageSrc || asset.assetId || fileName;
result.push({
name: fileName,
isDirectory: false,
@@ -123,7 +173,11 @@ export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
source: 'remote',
remoteObjectKey: asset.objectKey,
asset: {
id: asset.assetId || asset.objectKey || asset.imageSrc || 'platform-image',
id:
asset.assetId ||
asset.objectKey ||
asset.imageSrc ||
'platform-image',
localPath,
assetKind: asset.assetKind ?? null,
},
@@ -133,9 +187,22 @@ export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
return result;
}
export function buildRootFiles(): ManagerFile[] {
return [
{ name: '本地项目素材', isDirectory: true, path: PROJECT_ASSETS_PATH },
{ name: '云端素材库', isDirectory: true, path: REMOTE_ASSETS_PATH },
export function buildRootFiles(
mode: AssetImporterMode = 'image',
): ManagerFile[] {
const roots: ManagerFile[] = [
{
name: mode === 'font' ? '本地项目字体' : '本地项目素材',
isDirectory: true,
path: PROJECT_ASSETS_PATH,
},
];
if (mode === 'image') {
roots.push({
name: '云端素材库',
isDirectory: true,
path: REMOTE_ASSETS_PATH,
});
}
return roots;
}
@@ -5,6 +5,7 @@ import {
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import type { FontAsset } from './types/FontAsset';
import type { SpriteAsset } from './types/SpriteAsset';
import type { UIDesignImage } from './types/UIDesignImage';
import type { UIDesignImageId } from './types/UIDesignImageId';
@@ -17,10 +18,6 @@ export type PreparedImageAsset<T> = {
previewUrl: string;
};
function basename(path: string) {
return path.replaceAll('\\', '/').split('/').at(-1) || 'image';
}
export function decodeImageSize(src: string): Promise<[number, number]> {
return new Promise((resolve, reject) => {
const image = new Image();
@@ -111,3 +108,13 @@ export async function prepareSpriteAssetBatch(
}),
);
}
export function prepareFontAssetBatch(
projectPath: string,
assets: readonly ImportedAsset[],
) {
return invoke<FontAsset[]>('prepare_ui_editor_project_fonts', {
projectPath,
assets,
});
}
@@ -1,4 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FontAssetId } from "./FontAssetId";
import type { FontAssetId } from './FontAssetId';
import type { FontAssetMetadata } from './FontAssetMetadata';
export type FontAsset = { asset_id: FontAssetId, };
export type FontAsset = {
asset_id: FontAssetId;
metadata: FontAssetMetadata;
path: string;
content_sha256: string;
};
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FontFormat } from './FontFormat';
export type FontAssetMetadata = {
family_name: string;
face_name: string;
weight: number;
italic: boolean;
format: FontFormat;
source_file_name: string;
};
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FontFormat = 'TrueType' | 'OpenType' | 'Woff' | 'Woff2';
@@ -0,0 +1,122 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { FontAsset } from './types/FontAsset';
export type UiEditorFontFaceState = {
cssFamily: string;
status: 'error' | 'loaded' | 'loading';
};
export function uiEditorPrivateFontFamily(assetId: string) {
return `ui-editor-font-${encodeURIComponent(assetId).replaceAll('%', '_')}`;
}
function fontMimeType(font: FontAsset) {
switch (font.metadata.format) {
case 'OpenType':
return 'font/otf';
case 'Woff':
return 'font/woff';
case 'Woff2':
return 'font/woff2';
case 'TrueType':
return 'font/ttf';
}
}
export function useUiEditorFontFaces(
projectPath: string,
fonts: Record<string, FontAsset>,
) {
const fontsRef = useRef(fonts);
fontsRef.current = fonts;
const signature = useMemo(
() =>
JSON.stringify(
Object.values(fonts)
.map((font) => [
font.asset_id,
font.path,
font.content_sha256,
font.metadata.weight,
font.metadata.italic,
])
.sort(([left], [right]) => String(left).localeCompare(String(right))),
),
[fonts],
);
const [states, setStates] = useState<Record<string, UiEditorFontFaceState>>(
{},
);
useEffect(() => {
const fontAssets = Object.values(fontsRef.current);
let cancelled = false;
const registered: Array<{ face: FontFace; url: string }> = [];
setStates(
Object.fromEntries(
fontAssets.map((font) => [
font.asset_id,
{
cssFamily: uiEditorPrivateFontFamily(font.asset_id),
status: 'loading' as const,
},
]),
),
);
for (const font of fontAssets) {
void (async () => {
const cssFamily = uiEditorPrivateFontFamily(font.asset_id);
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;
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),
});
await face.load();
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
document.fonts.add(face);
registered.push({ face, url });
setStates((current) => ({
...current,
[font.asset_id]: { cssFamily, status: 'loaded' },
}));
} catch {
if (!cancelled) {
setStates((current) => ({
...current,
[font.asset_id]: { cssFamily, status: 'error' },
}));
}
}
})();
}
return () => {
cancelled = true;
for (const item of registered) {
document.fonts?.delete(item.face);
URL.revokeObjectURL(item.url);
}
};
}, [projectPath, signature]);
return states;
}
@@ -2,7 +2,6 @@ import type { CSSProperties } from 'react';
import type { FillMethod } from '../types/FillMethod';
import type { FontSizing } from '../types/FontSizing';
import type { FontStyle } from '../types/FontStyle';
import type { HorizontalTextOverflow } from '../types/HorizontalTextOverflow';
import type { ImageComponent } from '../types/ImageComponent';
import type { ImageType } from '../types/ImageType';
@@ -215,21 +214,6 @@ function fontSize(sizing: FontSizing): number {
return Math.max(1, finite(sizing.BestFit.max, 'BestFit.max'));
}
function fontStyle(
style: FontStyle,
): Pick<CSSProperties, 'fontStyle' | 'fontWeight'> {
switch (style) {
case 'Bold':
return { fontStyle: 'normal', fontWeight: 700 };
case 'Italic':
return { fontStyle: 'italic', fontWeight: 400 };
case 'BoldItalic':
return { fontStyle: 'italic', fontWeight: 700 };
case 'Normal':
return { fontStyle: 'normal', fontWeight: 400 };
}
}
function alignment(
alignmentValue: TextAlignment,
): Pick<CSSProperties, 'alignItems' | 'justifyContent' | 'textAlign'> {
@@ -269,7 +253,8 @@ export function textComponentToCss(component: TextComponent): CSSProperties {
color: `rgba(${red}, ${green}, ${blue}, ${Math.min(255, Math.max(0, alpha)) / 255})`,
fontFamily: SYSTEM_UI_FONT_STACK,
fontSize: `${fontSize(component.font_sizing)}px`,
...fontStyle(component.font_style),
fontStyle: 'normal',
fontWeight: 400,
...alignment(component.alignment),
lineHeight: finite(component.line_spacing, 'line_spacing'),
whiteSpace: horizontalOverflow === 'Wrap' ? 'normal' : 'nowrap',
-10
View File
@@ -4453,16 +4453,6 @@ iframe.preview-frame {
min-height: 0;
}
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
grid-template-rows: auto minmax(0, 1fr);
}
.game-workbench-stage[data-resource-view-state='resources.ui-editor']
> main {
grid-row: 2;
min-height: 0;
}
.game-workbench-toolbar {
display: flex;
align-items: center;
@@ -107,12 +107,6 @@ const videoExtension = /\.(mp4|webm|mov)$/iu;
export function projectResourceCardPreviewKind(
resource: ProjectResource,
): ProjectResourceCardPreviewKind {
// TODO(ui-design-preview): render the persisted UI Tree once UI State is
// stored at the resource path; the registered resource currently uses the
// normal placeholder visual.
if (resource.subtype === 'UI') {
return 'placeholder';
}
if (resource.category === 'version') {
return 'version';
}
@@ -76,7 +76,6 @@ export function classifyProjectedResource(input: {
return 'audio';
}
if (
normalizedKind === 'ui' ||
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
artExtension.test(normalizedPath) ||
@@ -18,9 +18,22 @@ export function EditorDialogs({
void controller.importAssets(assets);
controller.closeImporter();
}}
maxItems={controller.importKind === 'design-image' ? 4 : 100}
maxFileSize={20 * 1024 * 1024}
acceptedMediaTypes={['image/png', 'image/jpeg', 'image/webp']}
maxItems={
controller.importKind === 'design-image'
? 4
: controller.importKind === 'font'
? 64
: 100
}
maxFileSize={
controller.importKind === 'font' ? 8 * 1024 * 1024 : 20 * 1024 * 1024
}
acceptedMediaTypes={
controller.importKind === 'font'
? ['font/ttf', 'font/otf', 'font/woff', 'font/woff2']
: ['image/png', 'image/jpeg', 'image/webp']
}
mode={controller.importKind === 'font' ? 'font' : 'image'}
/>
<ThemedModal
@@ -31,7 +44,8 @@ export function EditorDialogs({
>
<h2 className="m-0 text-base font-semibold"> State</h2>
<p className="text-sm leading-6 text-(--platform-text-soft)">
State
State
</p>
<div className="mt-5 flex justify-end gap-2">
<button
@@ -66,6 +80,7 @@ export function EditorDialogs({
<li>{impact?.removedTreeCount ?? 0}</li>
<li>{impact?.clearedSlaveToCount ?? 0}</li>
<li>{impact?.clearedTargetGraphicCount ?? 0}</li>
<li>{impact?.clearedFontCount ?? 0}</li>
</ul>
<div className="mt-5 flex justify-end gap-2">
<button
@@ -295,6 +295,8 @@ function renderComponentEditor(
<TextPanel
component={component.Text}
fonts={props.fonts}
fontFaces={props.fontFaces}
projectPath={props.projectPath}
readOnly={readOnly}
onChange={(next) => updateComponent(index, { Text: next })}
/>
@@ -3,12 +3,15 @@ import type { FontAsset } from '../../../../../features/ui-editor/types/FontAsse
import type { ImageComponent } from '../../../../../features/ui-editor/types/ImageComponent';
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
import type { TextComponent } from '../../../../../features/ui-editor/types/TextComponent';
import type { UiEditorFontFaceState } from '../../../../../features/ui-editor/useUiEditorFontFaces';
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
export type ComponentPanelProps = {
components: Component[];
sprites: Record<string, SpriteAsset>;
fonts: Record<string, FontAsset>;
fontFaces: Record<string, UiEditorFontFaceState>;
projectPath: string;
readOnly: boolean;
onSetComponents: (
components: Component[],
@@ -36,4 +39,6 @@ export type ImageEditorProps = ComponentEditorProps<ImageComponent> & {
export type TextEditorProps = ComponentEditorProps<TextComponent> & {
fonts: Record<string, FontAsset>;
fontFaces: Record<string, UiEditorFontFaceState>;
projectPath: string;
};
@@ -1,4 +1,4 @@
import { Boxes, LockKeyhole, Trash2 } from 'lucide-react';
import { Boxes, Trash2 } from 'lucide-react';
import type {
InputHTMLAttributes,
ReactNode,
@@ -8,6 +8,7 @@ import type {
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';
import { UI_DESIGN_IMAGE_ROLES } from '../../model';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { ComponentPanel } from './Components/ComponentPanel';
@@ -16,8 +17,10 @@ import TransformEditor from './Transform/TransformEditor';
type SelectedNode = NonNullable<UiEditorPageController['selectedNode']>;
type SelectedSprite = NonNullable<UiEditorPageController['selectedSprite']>;
type SelectedFont = NonNullable<UiEditorPageController['selectedFont']>;
type ActiveImage = NonNullable<UiEditorPageController['activeImage']>;
type SelectedSpriteId = NonNullable<UiEditorPageController['selectedSpriteId']>;
type SelectedFontId = NonNullable<UiEditorPageController['selectedFontId']>;
type ActiveImageId = NonNullable<UiEditorPageController['activeImageId']>;
type InspectorView =
@@ -33,6 +36,13 @@ type InspectorView =
previewUrl: string | undefined;
referenceCount: number;
}
| {
kind: 'font';
font: SelectedFont;
fontId: SelectedFontId;
referenceCount: number;
loadStatus: 'error' | 'loaded' | 'loading' | undefined;
}
| {
kind: 'image';
image: ActiveImage;
@@ -44,6 +54,7 @@ type InspectorView =
const INSPECTOR_VIEW_TITLES: Record<InspectorView['kind'], string> = {
node: '当前节点',
sprite: '当前素材',
font: '当前字体',
image: '当前界面',
empty: '当前界面',
};
@@ -59,7 +70,6 @@ export function InspectorSidebar({
controller: UiEditorPageController;
}) {
const view = getInspectorView(controller);
const isReadOnly = controller.editor.isLocked;
let inspectorContent: ReactNode;
switch (view.kind) {
case 'node':
@@ -78,6 +88,8 @@ export function InspectorSidebar({
onTransformChange={controller.setNodeTransform}
sprites={controller.sprites}
fonts={controller.editor.state.font_assets}
fontFaces={controller.fontFaces}
projectPath={controller.projectPath}
onSetComponents={controller.setNodeComponents}
onInsertComponent={controller.insertNodeComponent}
onDeleteComponent={controller.deleteNodeComponent}
@@ -100,6 +112,18 @@ export function InspectorSidebar({
/>
);
break;
case 'font':
inspectorContent = (
<FontInspector
font={view.font}
fontId={view.fontId}
referenceCount={view.referenceCount}
loadStatus={view.loadStatus}
onDelete={() => controller.requestFontRemoval(view.fontId)}
deleteDisabled={controller.editor.isLocked}
/>
);
break;
case 'image':
inspectorContent = (
<ImageInspector
@@ -125,21 +149,12 @@ export function InspectorSidebar({
}
return (
<aside
aria-readonly={isReadOnly}
className={`flex min-h-0 flex-col overflow-y-auto border-l p-4 transition-colors ${isReadOnly ? 'border-slate-300 bg-slate-100/90' : 'border-(--platform-subpanel-border) bg-(--platform-subpanel-fill)'}`}
>
<aside className="flex min-h-0 flex-col overflow-y-auto border-l border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-4">
<div className="flex items-center gap-2">
<Boxes size={15} />
<h2 className="m-0 text-sm font-semibold">
{INSPECTOR_VIEW_TITLES[view.kind]}
</h2>
{isReadOnly ? (
<span className="ml-auto inline-flex items-center gap-1 rounded-full border border-slate-300 bg-slate-200 px-2 py-1 text-[10px] font-semibold text-slate-600">
<LockKeyhole size={12} aria-hidden="true" />
</span>
) : null}
</div>
{inspectorContent}
</aside>
@@ -152,10 +167,14 @@ function getInspectorView(controller: UiEditorPageController): InspectorView {
activeImageId,
selectedSprite,
selectedSpriteId,
selectedFont,
selectedFontId,
selectedNode,
selectedNodeParentSize,
previewUrls,
spriteReferenceCounts,
fontReferenceCounts,
fontFaces,
pageOptions,
} = controller;
@@ -177,6 +196,16 @@ function getInspectorView(controller: UiEditorPageController): InspectorView {
};
}
if (selectedFont && selectedFontId) {
return {
kind: 'font',
font: selectedFont,
fontId: selectedFontId,
referenceCount: fontReferenceCounts[selectedFontId] ?? 0,
loadStatus: fontFaces[selectedFontId]?.status,
};
}
if (activeImage && activeImageId) {
return {
kind: 'image',
@@ -200,6 +229,8 @@ function NodeInspector({
onTransformChange,
sprites,
fonts,
fontFaces,
projectPath,
onSetComponents,
onInsertComponent,
onDeleteComponent,
@@ -215,6 +246,8 @@ function NodeInspector({
onTransformChange: UiEditorPageController['setNodeTransform'];
sprites: UiEditorPageController['sprites'];
fonts: UiEditorPageController['editor']['state']['font_assets'];
fontFaces: UiEditorPageController['fontFaces'];
projectPath: string;
onSetComponents: UiEditorPageController['setNodeComponents'];
onInsertComponent: UiEditorPageController['insertNodeComponent'];
onDeleteComponent: UiEditorPageController['deleteNodeComponent'];
@@ -318,6 +351,8 @@ function NodeInspector({
components={node.components}
sprites={sprites}
fonts={fonts}
fontFaces={fontFaces}
projectPath={projectPath}
readOnly={readOnly}
onSetComponents={onSetComponents}
onInsertComponent={onInsertComponent}
@@ -441,6 +476,72 @@ function SpriteInspector({
);
}
function FontInspector({
font,
fontId,
referenceCount,
loadStatus,
onDelete,
deleteDisabled,
}: {
font: SelectedFont;
fontId: SelectedFontId;
referenceCount: number;
loadStatus: 'error' | 'loaded' | 'loading' | undefined;
onDelete: () => void;
deleteDisabled: boolean;
}) {
return (
<div className="mt-4 space-y-3">
<div className="rounded-xl border border-(--platform-subpanel-border) bg-white/45 p-4">
<span
className="block truncate text-3xl"
style={
loadStatus === 'loaded'
? {
fontFamily: JSON.stringify(uiEditorPrivateFontFamily(fontId)),
}
: undefined
}
>
Aa 123
</span>
</div>
<InspectorReadout label="字体家族" value={font.metadata.family_name} />
<InspectorReadout label="字体面" value={font.metadata.face_name} />
<InspectorReadout label="字重" value={String(font.metadata.weight)} />
<InspectorReadout
label="样式"
value={font.metadata.italic ? 'Italic' : 'Normal'}
/>
<InspectorReadout label="源文件" value={font.metadata.source_file_name} />
<InspectorReadout label="引用数" value={String(referenceCount)} />
{loadStatus === 'error' ? (
<p className="m-0 rounded-lg bg-amber-50 p-2 text-xs text-amber-700">
WebView
</p>
) : null}
<ResourceId value={fontId} />
<DeleteResourceButton
label="删除"
disabled={deleteDisabled}
onClick={onDelete}
/>
</div>
);
}
function InspectorReadout({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg bg-black/4 p-2 text-xs">
<span className="block text-[10px] text-(--platform-text-soft)">
{label}
</span>
<span className="break-all">{value}</span>
</div>
);
}
function ImageInspector({
image,
imageId,
@@ -4,9 +4,11 @@ import {
ChevronUp,
Crosshair,
Info,
LockKeyhole,
Maximize2,
Move,
SlidersHorizontal,
Unlock,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -386,7 +388,10 @@ export function TransformEditor({
geometry?.invalid;
return (
<section className="w-full min-w-72.5 max-w-none overflow-visible rounded-2xl border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) text-(--platform-text-strong) shadow-[0_16px_40px_rgb(91_62_45/10%)]">
<section
aria-readonly={readOnly}
className={`w-full min-w-72.5 max-w-none overflow-visible rounded-2xl border text-(--platform-text-strong) shadow-[0_16px_40px_rgb(91_62_45/10%)] ${readOnly ? 'border-slate-300 bg-slate-100/90' : 'border-(--platform-subpanel-border) bg-(--platform-subpanel-fill)'}`}
>
<header className="flex items-center justify-between border-b border-(--platform-subpanel-border) px-4 py-3">
<div className="flex items-center gap-2">
<span className="grid size-8 place-items-center rounded-xl bg-orange-100 text-orange-600">
@@ -402,6 +407,17 @@ export function TransformEditor({
</p>
</div>
</div>
{readOnly ? (
<span className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-slate-200 px-2 py-1 text-[10px] font-semibold text-slate-600">
<LockKeyhole size={12} aria-hidden="true" />
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-[10px] font-semibold text-emerald-600">
<Unlock size={12} aria-hidden="true" />
</span>
)}
</header>
<div className="grid gap-4 p-4">
@@ -12,7 +12,7 @@ import {
CanvasWorld,
ZoomControls,
} from '@genarrative/image-canvas-react';
import { Image as ImageIcon, Minus, Plus } from 'lucide-react';
import { Image as ImageIcon, Minus, Plus, ScanSearch } from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
@@ -44,7 +44,6 @@ export function PreviewWorkspace({
const [spaceHeld, setSpaceHeld] = useState(false);
const [renderMode, setRenderMode] =
useState<UiEditorRenderMode>('editor-overlay');
const [showFrame, setShowFrame] = useState(false);
const tree = controller.treeForActiveImage ?? null;
const logicalSize = useMemo(() => {
if (!activeImage) return null;
@@ -293,16 +292,6 @@ export function PreviewWorkspace({
</button>
</div>
{renderMode === 'final-preview' ? (
<button
type="button"
className={`rounded-md border px-2 py-1 text-[10px] ${showFrame ? 'border-orange-500 bg-orange-50 text-orange-700' : 'border-(--platform-subpanel-border) text-(--platform-text-soft)'}`}
aria-pressed={showFrame}
onClick={() => setShowFrame((current) => !current)}
>
Show frame
</button>
) : null}
<span className="text-[10px] text-(--platform-text-soft)">
{Object.keys(controller.images).length} ·{' '}
{Object.keys(controller.sprites).length}
@@ -351,12 +340,12 @@ export function PreviewWorkspace({
<UiTreeRenderer
tree={tree}
renderMode={renderMode}
showFrame={showFrame}
selectedNodeId={controller.selectedNodeId}
resources={{
previewUrls,
sprites: controller.sprites,
fonts: editor.state.font_assets,
fontFaces: controller.fontFaces,
}}
onSelectNode={controller.selectNode}
onNodePointerDown={onNodePointerDown}
@@ -446,6 +435,13 @@ export function PreviewWorkspace({
>
</button>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg bg-orange-500 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.checkPrerequisites}
>
<ScanSearch size={14} />
</button>
</footer>
</section>
);
@@ -20,7 +20,6 @@ type NodePointerDown = (
type UiTreeRendererProps = {
tree: UITree | null;
renderMode: UiEditorRenderMode;
showFrame: boolean;
selectedNodeId: NodeId | null;
resources: PreviewComponentResources;
onSelectNode: (id: NodeId) => void;
@@ -57,7 +56,6 @@ function RenderNode({
node,
isRoot,
renderMode,
showFrame,
selectedNodeId,
resources,
onSelectNode,
@@ -78,14 +76,13 @@ function RenderNode({
}
const isEditorOverlay = renderMode === 'editor-overlay';
const isFrameVisible = isEditorOverlay || showFrame;
return (
<div
data-node-id={node.id}
className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`}
style={{
...geometry,
...(isFrameVisible
...(isEditorOverlay
? {
outline:
selectedNodeId === node.id
@@ -107,28 +104,25 @@ function RenderNode({
onPointerMove={onNodePointerMove}
onPointerUp={onNodePointerUp}
onPointerCancel={onNodePointerUp}
title={isFrameVisible ? node.metadata.name || undefined : undefined}
title={isEditorOverlay ? node.metadata.name || undefined : undefined}
>
{isFrameVisible && node.metadata.name ? (
{isEditorOverlay && node.metadata.name ? (
<span className="pointer-events-none absolute left-0 top-0 max-w-full truncate bg-blue-600/80 px-1 text-[9px] leading-4 text-white">
{node.metadata.name}
</span>
) : null}
{renderMode === 'final-preview'
? node.components.map((component, index) => (
<ComponentView
key={`${node.id}:component:${index}`}
component={component}
resources={resources}
/>
))
: null}
{node.components.map((component, index) => (
<ComponentView
key={`${node.id}:component:${index}`}
component={component}
resources={resources}
/>
))}
{node.children.map((child) => (
<RenderNode
key={child.id}
node={child}
renderMode={renderMode}
showFrame={showFrame}
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
@@ -141,7 +135,7 @@ function RenderNode({
viewportScale={viewportScale}
/>
))}
{isFrameVisible && !isRoot && selectedNodeId === node.id
{isEditorOverlay && !isRoot && selectedNodeId === node.id
? RESIZE_HANDLES.map((handle) => (
<div
key={handle.id}
@@ -1,8 +1,10 @@
import type { FontAsset } from '../../../../../features/ui-editor/types/FontAsset';
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
import type { UiEditorFontFaceState } from '../../../../../features/ui-editor/useUiEditorFontFaces';
export type PreviewComponentResources = {
previewUrls: Record<string, string>;
sprites: Record<string, SpriteAsset>;
fonts: Record<string, FontAsset>;
fontFaces: Record<string, UiEditorFontFaceState>;
};
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import {
buildProjectFiles,
buildRootFiles,
} from '../src/components/AssetImporter/utils';
describe('AssetImporter font mode', () => {
it('discovers only registered font candidates and hides the remote root', () => {
const assets = [
{
id: 'font-1',
localPath: 'assets/fonts/body.ttf',
mediaType: 'font/ttf',
},
{
id: 'image-1',
localPath: 'assets/hero.png',
mediaType: 'image/png',
},
{
id: 'font-by-extension',
localPath: 'assets/fonts/title.otf',
mediaType: 'application/octet-stream',
},
];
const files = buildProjectFiles(assets, 'font').filter(
(entry) => !entry.isDirectory,
);
expect(files.map((entry) => entry.asset?.id)).toEqual([
'font-1',
'font-by-extension',
]);
expect(buildRootFiles('font').map((entry) => entry.name)).toEqual([
'本地项目字体',
]);
});
});

Some files were not shown because too many files have changed in this diff Show More