修复 UI 编辑器导出与预览一致性
修复保存按钮在 AI 运行期间的禁用状态与生成成功提示失效 加强字体资源路径校验并复用生成代码字体 CSS 让布局错误显式传播并为生成文件名追加 asset ID 摘要 统一前端与 Rust 的 Radial90 顺时针填充方向 补充代码生成不推进项目 revision 的技术方案与决策记录
This commit is contained in:
@@ -23,7 +23,7 @@ pub(super) fn asset_url(path: &str) -> Result<String, String> {
|
||||
|| path.contains('\\')
|
||||
|| path.split('/').any(|part| part == "..")
|
||||
|| path.chars().any(|character| {
|
||||
character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')')
|
||||
character.is_control() || matches!(character, '<' | '>' | '\'' | '"' | '`' | '(' | ')')
|
||||
})
|
||||
{
|
||||
return Err(format!("资源路径无效:{path}"));
|
||||
|
||||
@@ -102,7 +102,7 @@ fn fill_clip_path(method: &FillMethod, amount: f32) -> Option<String> {
|
||||
trim_float((1.0 - amount) * 100.0)
|
||||
)),
|
||||
FillMethod::Radial90 { origin, clockwise } => Some(conic_mask(
|
||||
radial_origin_angle_90(*origin),
|
||||
radial_origin_angle_90(*origin) - if *clockwise { 90.0 } else { 0.0 },
|
||||
*clockwise,
|
||||
amount,
|
||||
90.0,
|
||||
|
||||
@@ -3,21 +3,24 @@ use crate::ui_editor::layout::control_layout::{Container, ControlLayout};
|
||||
|
||||
const UI_SCALE: &str = "var(--ui-scale, 1)";
|
||||
|
||||
pub(super) fn container_style(container: &Container) -> String {
|
||||
match container {
|
||||
pub(super) fn container_style(container: &Container) -> Result<String, String> {
|
||||
Ok(match container {
|
||||
Container::None => String::new(),
|
||||
Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())),
|
||||
Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())),
|
||||
Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation).unwrap_or_default(), scaled_px(*v_separation).unwrap_or_default()),
|
||||
Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left).unwrap_or_default(), scaled_px(*margin_top).unwrap_or_default(), scaled_px(*margin_right).unwrap_or_default(), scaled_px(*margin_bottom).unwrap_or_default()),
|
||||
Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?),
|
||||
Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?),
|
||||
Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation)?, scaled_px(*v_separation)?),
|
||||
Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left)?, scaled_px(*margin_top)?, scaled_px(*margin_right)?, scaled_px(*margin_bottom)?),
|
||||
Container::Center { .. } => "display:grid;place-items:center;min-width:0;min-height:0;".to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container) -> String {
|
||||
let min_w = scaled_px(layout.custom_minimum_size.x).unwrap_or_default();
|
||||
let min_h = scaled_px(layout.custom_minimum_size.y).unwrap_or_default();
|
||||
match parent {
|
||||
pub(super) fn child_container_style(
|
||||
layout: &ControlLayout,
|
||||
parent: &Container,
|
||||
) -> Result<String, String> {
|
||||
let min_w = scaled_px(layout.custom_minimum_size.x)?;
|
||||
let min_h = scaled_px(layout.custom_minimum_size.y)?;
|
||||
Ok(match parent {
|
||||
Container::HBox { .. } => format!(
|
||||
"min-width:{min_w};min-height:{min_h};{}align-self:{};",
|
||||
flex_grow(
|
||||
@@ -44,7 +47,7 @@ pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container)
|
||||
Container::Grid { .. } | Container::None => {
|
||||
format!("min-width:{min_w};min-height:{min_h};")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn scaled_px(value: f32) -> Result<String, String> {
|
||||
|
||||
@@ -57,6 +57,15 @@ pub(crate) fn render_ui_design_state_js(
|
||||
state: &State,
|
||||
) -> Result<(String, Vec<String>, usize), String> {
|
||||
// 生成阶段只渲染已由保存流程 validate_state 校验过的 State;不重复执行领域校验。
|
||||
let font_faces = state
|
||||
.font_assets
|
||||
.values()
|
||||
.map(|font| {
|
||||
let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str()));
|
||||
font_face_rule(font, &family)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("");
|
||||
let mut exports = Vec::with_capacity(state.ui_trees.len());
|
||||
let mut modules = Vec::with_capacity(state.ui_trees.len());
|
||||
let mut node_count = 0usize;
|
||||
@@ -80,15 +89,6 @@ pub(crate) fn render_ui_design_state_js(
|
||||
}),
|
||||
)
|
||||
.into_string();
|
||||
let fonts = state
|
||||
.font_assets
|
||||
.values()
|
||||
.map(|font| {
|
||||
let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str()));
|
||||
font_face_rule(font, &family)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("");
|
||||
let root = render_node_with_scale(
|
||||
state,
|
||||
&tree.root,
|
||||
@@ -99,8 +99,10 @@ pub(crate) fn render_ui_design_state_js(
|
||||
node_count += count_nodes(&tree.root);
|
||||
let mut fragment = String::new();
|
||||
fragment.push_str(&tree_comment);
|
||||
if !fonts.is_empty() {
|
||||
fragment.push_str(&html! { style data-ui-fonts { (PreEscaped(fonts)) } }.into_string());
|
||||
if !font_faces.is_empty() {
|
||||
fragment.push_str(
|
||||
&html! { style data-ui-fonts { (PreEscaped(font_faces.as_str())) } }.into_string(),
|
||||
);
|
||||
}
|
||||
fragment.push_str(&root);
|
||||
let escaped = escape_template_literal(&pretty_html_fragment(&fragment));
|
||||
@@ -178,9 +180,9 @@ fn render_node_with_scale(
|
||||
));
|
||||
}
|
||||
style.push_str("border:0;outline:0;background:transparent;overflow:visible;");
|
||||
style.push_str(&container_style(&node.layout.container));
|
||||
style.push_str(&container_style(&node.layout.container)?);
|
||||
if let Some(parent) = parent_container {
|
||||
style.push_str(&child_container_style(&node.layout, parent));
|
||||
style.push_str(&child_container_style(&node.layout, parent)?);
|
||||
}
|
||||
let comment = html_comment(
|
||||
"genarrative-ui-node",
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::ui_editor::utils::UIDesignImageId;
|
||||
use crate::*;
|
||||
use nalgebra::Vector2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
@@ -204,11 +205,14 @@ fn generated_file_stem(asset_id: &str) -> String {
|
||||
stem.push('_');
|
||||
}
|
||||
}
|
||||
if stem.is_empty() {
|
||||
"ui-design".to_string()
|
||||
let readable_stem = if stem.is_empty() {
|
||||
"ui-design"
|
||||
} else {
|
||||
stem
|
||||
}
|
||||
stem.as_str()
|
||||
};
|
||||
// 保留可读前缀,并追加摘要以避免不同 ID 映射到同一路径。
|
||||
let digest = format!("{:x}", Sha256::digest(asset_id.as_bytes()));
|
||||
format!("{readable_stem}-{}", &digest[..16])
|
||||
}
|
||||
|
||||
pub(crate) fn save_ui_design_state_at(
|
||||
@@ -1001,6 +1005,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_file_stem_keeps_distinct_asset_ids_distinct() {
|
||||
let first = generated_file_stem("a.b");
|
||||
let second = generated_file_stem("a/b");
|
||||
assert_ne!(first, second);
|
||||
assert!(first.starts_with("a_b-"));
|
||||
assert_eq!(first.len(), "a_b-".len() + 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sprite_asset_when_saving_a_dragged_node_transform() {
|
||||
let (directory, asset_id) = fixture();
|
||||
|
||||
@@ -80,14 +80,22 @@ function fillClipPath(method: FillMethod, amount: number): string | undefined {
|
||||
const sweep = amount * maxSweep;
|
||||
const origin = config.origin;
|
||||
const originAngle =
|
||||
origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight'
|
||||
? 0
|
||||
: origin === 'Right' || origin === 'BottomRight'
|
||||
? 90
|
||||
: origin === 'Bottom' || origin === 'BottomLeft'
|
||||
? 180
|
||||
: 270;
|
||||
const start = config.clockwise ? originAngle : originAngle - sweep;
|
||||
kind === 'Radial90'
|
||||
? origin === 'TopLeft'
|
||||
? 0
|
||||
: origin === 'TopRight'
|
||||
? 90
|
||||
: origin === 'BottomRight'
|
||||
? 180
|
||||
: 270
|
||||
: origin === 'Top'
|
||||
? 0
|
||||
: origin === 'Right'
|
||||
? 90
|
||||
: origin === 'Bottom'
|
||||
? 180
|
||||
: 270;
|
||||
const start = originAngle - (config.clockwise ? 90 : sweep);
|
||||
// CSS conic gradients provide a deterministic browser preview for radial
|
||||
// fills. Exact engine parity is intentionally deferred.
|
||||
return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import {
|
||||
@@ -69,6 +69,18 @@ export default function UiEditorPage({
|
||||
const [generateAfterWarning, setGenerateAfterWarning] = useState(false);
|
||||
const [generateSuccess, setGenerateSuccess] = useState<string | null>(null);
|
||||
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
|
||||
const saveDisabled =
|
||||
session.save.isSaving ||
|
||||
session.save.isGenerating ||
|
||||
session.save.isLoading ||
|
||||
session.workflow.isAiRunning ||
|
||||
Boolean(session.save.loadError) ||
|
||||
session.save.persistedRevision === null ||
|
||||
session.save.isLocked;
|
||||
|
||||
useEffect(() => {
|
||||
if (session.save.isDirty) setGenerateSuccess(null);
|
||||
}, [session.save.isDirty]);
|
||||
|
||||
async function save(afterReturn = false) {
|
||||
if (await session.save.save()) {
|
||||
@@ -151,14 +163,7 @@ export default function UiEditorPage({
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60"
|
||||
disabled={
|
||||
session.save.isSaving ||
|
||||
session.save.isGenerating ||
|
||||
session.save.isLoading ||
|
||||
Boolean(session.save.loadError) ||
|
||||
session.save.persistedRevision === null ||
|
||||
session.save.isLocked
|
||||
}
|
||||
disabled={saveDisabled}
|
||||
onClick={() => requestSave()}
|
||||
>
|
||||
{session.save.isSaving ? '保存中…' : '保存'}
|
||||
@@ -166,14 +171,7 @@ export default function UiEditorPage({
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-orange-600 px-3 py-1.5 text-sm font-semibold text-orange-700 disabled:opacity-60"
|
||||
disabled={
|
||||
session.save.isSaving ||
|
||||
session.save.isGenerating ||
|
||||
session.save.isLoading ||
|
||||
Boolean(session.save.loadError) ||
|
||||
session.save.persistedRevision === null ||
|
||||
session.save.isLocked
|
||||
}
|
||||
disabled={saveDisabled}
|
||||
onClick={requestSaveAndGenerate}
|
||||
>
|
||||
{saveAndGenerateLabel(
|
||||
@@ -280,13 +278,7 @@ export default function UiEditorPage({
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
|
||||
disabled={
|
||||
session.save.isSaving ||
|
||||
session.save.isGenerating ||
|
||||
Boolean(session.save.loadError) ||
|
||||
session.save.persistedRevision === null ||
|
||||
session.save.isLocked
|
||||
}
|
||||
disabled={saveDisabled}
|
||||
onClick={() => requestSave(true)}
|
||||
>
|
||||
{session.save.isSaving ? '保存中…' : '保存并返回'}
|
||||
|
||||
Reference in New Issue
Block a user