支持 UI 编辑器项目字体导入
新增 Rust 字体校验、复制登记与安全读取链路 新增项目字体面板、Text 字体绑定和预览回退 清理废弃 UI 设计持久化入口并补齐测试文档
This commit is contained in:
+7
@@ -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, ®istered.id, ®istered.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',
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -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 })}
|
||||
/>
|
||||
|
||||
+5
@@ -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;
|
||||
};
|
||||
|
||||
+113
-12
@@ -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,
|
||||
|
||||
+17
-1
@@ -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">
|
||||
|
||||
+9
-13
@@ -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>
|
||||
);
|
||||
|
||||
+11
-17
@@ -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
Reference in New Issue
Block a user