feat(panels): multi-fill + stroke-mode editor, property-panel polish

Batch commit of the property-panel work: multi-fill Fill editor, stroke
edit modes (uniform/per-side), clip support, plus polish — every
property-panel icon centred in its hover wash (fill X, padding/stroke
gears, gradient/effect remove), justify-row + size washes fit-content,
stroke duplicate width removed (inline width Single-only, per-side grid
otherwise), Openverse register link fixed + hover, ImageSrc Arc bump.

Known issue: stroke-width edit does not persist on a node whose
node_stroke_slot is None (see cmd_set_node_stroke_width) — follow-up.
This commit is contained in:
Kayshen-X 2026-06-21 17:24:42 +08:00
parent ec5ba9c283
commit 00a4616b11
10 changed files with 235 additions and 35 deletions

View file

@ -17,6 +17,12 @@ impl EditorState {
if nodes.is_empty() {
return false;
}
let mut nodes = nodes;
let replacement = crate::command_root_replace::prepare_root_frame_replacement(
self.active_children(),
&mut nodes,
parent_id,
);
if parent_id.is_real() {
// Accept any container (matches `cmd_insert_subtree`), including an
// empty one whose `children` is still `None` — the insert below
@ -29,7 +35,12 @@ impl EditorState {
}
}
let live = self.collect_node_ids();
let mut live = self.collect_node_ids();
if let Some(replacement) = replacement.as_ref() {
live.remove(crate::command_root_replace::replacement_node_id(
replacement,
));
}
let mut incoming = HashSet::new();
if !nodes
.iter()
@ -47,7 +58,13 @@ impl EditorState {
};
children.extend(nodes);
} else {
self.active_children_mut().extend(nodes);
let roots = self.active_children_mut();
if let Some(replacement) = replacement.as_ref() {
if !crate::command_root_replace::remove_root_frame_replacement(roots, replacement) {
return false;
}
}
roots.extend(nodes);
}
true
}

View file

@ -48,6 +48,39 @@ fn insert_authored_subtree_preserves_ids_for_layered_workflow() {
assert_eq!(section.id_str(), "hero");
}
#[test]
fn insert_authored_root_frame_replaces_empty_root_frame() {
let mut s = state_with(vec![frame(
"default",
"Frame",
30.0,
40.0,
100.0,
100.0,
vec![],
)]);
assert!(s.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![frame(
"food-home",
"Food App Home",
0.0,
0.0,
402.0,
874.0,
vec![rect("hero", "Hero", 0.0, 0.0, 402.0, 120.0)],
)],
parent_id: NodeId::NONE,
page_id: None,
}));
let children = s.active_children();
assert_eq!(children.len(), 1, "empty default frame should be replaced");
assert_eq!(children[0].id_str(), "food-home");
assert_eq!(children[0].base().x, Some(30.0));
assert_eq!(children[0].base().y, Some(40.0));
}
#[test]
fn insert_authored_subtree_accepts_empty_container_parent() {
// Regression: a container whose `children` is still `None` (here a `rect`,

View file

@ -810,6 +810,11 @@ impl EditorState {
};
let mut taken: HashSet<NodeId> = self.collect_node_ids();
let mut nodes = nodes;
let replacement = crate::command_root_replace::prepare_root_frame_replacement(
self.active_children(),
&mut nodes,
parent_id,
);
if !remap_subtree_ids(&mut nodes, &mut next_id, &mut taken) {
return false;
}
@ -824,7 +829,13 @@ impl EditorState {
};
slot.extend(nodes);
} else {
self.active_children_mut().extend(nodes);
let roots = self.active_children_mut();
if let Some(replacement) = replacement.as_ref() {
if !crate::command_root_replace::remove_root_frame_replacement(roots, replacement) {
return false;
}
}
roots.extend(nodes);
}
true
}

View file

@ -0,0 +1,51 @@
//! Shared empty-root-frame replacement for generated design inserts.
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use jian_ops_schema::node::PenNode;
pub(crate) struct RootFrameReplacement {
node_id: NodeId,
}
pub(crate) fn prepare_root_frame_replacement(
roots: &[PenNode],
nodes: &mut [PenNode],
parent_id: &NodeId,
) -> Option<RootFrameReplacement> {
if parent_id.is_real() || nodes.len() != 1 || !matches!(nodes[0], PenNode::Frame(_)) {
return None;
}
let empty = roots.iter().find(|node| is_empty_frame(node))?;
if let Some(x) = empty.base().x {
nodes[0].base_mut().x = Some(x);
}
if let Some(y) = empty.base().y {
nodes[0].base_mut().y = Some(y);
}
Some(RootFrameReplacement {
node_id: NodeId::new(empty.id_str()),
})
}
pub(crate) fn remove_root_frame_replacement(
roots: &mut Vec<PenNode>,
replacement: &RootFrameReplacement,
) -> bool {
let Some(index) = roots
.iter()
.position(|node| node.id_str() == replacement.node_id.as_str())
else {
return false;
};
roots.remove(index);
true
}
pub(crate) fn replacement_node_id(replacement: &RootFrameReplacement) -> &NodeId {
&replacement.node_id
}
fn is_empty_frame(node: &PenNode) -> bool {
matches!(node, PenNode::Frame(_)) && node.children().map(|c| c.is_empty()).unwrap_or(true)
}

View file

@ -13,7 +13,7 @@ use crate::command::EditorCommand;
use crate::command_node::remap_subtree_ids;
use crate::node_id::NodeId;
use crate::pen_node_ext::{make_group, make_path, PenNodeExt};
use crate::test_support::state_with;
use crate::test_support::{frame, rect, state_with};
use std::collections::HashSet;
// --- remap_subtree_ids ----------------------------------------------
@ -67,6 +67,40 @@ fn insert_subtree_nests_children_under_root() {
assert!(s.find_duplicate_id().is_none());
}
#[test]
fn insert_subtree_root_frame_replaces_empty_root_frame() {
let mut s = state_with(vec![frame(
"default",
"Frame",
30.0,
40.0,
100.0,
100.0,
vec![],
)]);
assert!(s.apply(EditorCommand::InsertSubtree {
nodes: vec![frame(
"ext-root",
"Food App Home",
0.0,
0.0,
402.0,
874.0,
vec![rect("hero", "Hero", 0.0, 0.0, 402.0, 120.0)],
)],
parent_id: NodeId::NONE,
page_id: None,
}));
let children = s.active_children();
assert_eq!(children.len(), 1, "empty default frame should be replaced");
assert_ne!(children[0].id_str(), "default");
assert_eq!(children[0].base().name.as_deref(), Some("Food App Home"));
assert_eq!(children[0].base().x, Some(30.0));
assert_eq!(children[0].base().y, Some(40.0));
}
#[test]
fn insert_subtree_rejects_empty() {
let mut s = state_with(vec![]);

View file

@ -34,6 +34,7 @@ pub mod command_node;
pub mod command_node_attrs;
pub mod command_promote;
pub mod command_refine;
mod command_root_replace;
pub mod command_style_replace;
pub mod component_browser_state;
pub mod components;

View file

@ -292,11 +292,18 @@ pub fn push_flex_action_rects(
(LayoutJustifyValue::SpaceAround, false),
];
for (i, (justify_value, circle_only)) in rows.into_iter().enumerate() {
let row_w = if circle_only { RADIO_GUTTER } else { gap_w };
// The numeric/Start row's target is just the radio circle — centre its
// hover-wash cell on the RADIO_SIZE glyph (the radio paints at gap_x,
// column-aligned with the other rows, so shift the cell left instead).
let (row_x, row_w) = if circle_only {
(gap_x - (RADIO_GUTTER - RADIO_SIZE) / 2.0, RADIO_GUTTER)
} else {
(gap_x, gap_w)
};
out.push((
PropertyPanelAction::SetLayoutJustify(justify_value),
Rect {
origin: Point2D::new(gap_x, gap_row_y(grid_y, i)),
origin: Point2D::new(row_x, gap_row_y(grid_y, i)),
size: Point2D::new(row_w, GAP_ROW_H),
},
));

View file

@ -294,9 +294,13 @@ pub fn editable_input_rects(
} else {
0.0
};
// Stroke width moved to the mode grid below — the hex hit rect now
// fills the row (matches the widened paint in property_panel_stroke).
let stroke_hex_w = usable_w - variable_w;
// Inline width only in Single mode (Axis/Individual use the per-side
// grid); must match paint_stroke_main_row so the hit rects don't
// linger where the width is no longer painted.
let inline = visible.stroke_edit_mode == op_editor_core::PaddingEditMode::Single;
let stroke_width_w = if inline { 60.0 } else { 0.0 };
let stroke_width_gap = if inline { 8.0 } else { 0.0 };
let stroke_hex_w = usable_w - stroke_width_w - stroke_width_gap - variable_w;
if !visible.stroke_variable_bound {
rects.push((
PropertyFocus::StrokeHex,
@ -306,6 +310,15 @@ pub fn editable_input_rects(
},
));
}
if inline {
rects.push((
PropertyFocus::StrokeWidth,
Rect {
origin: Point2D::new(x0 + PAD_X + stroke_hex_w + variable_w + 8.0, y),
size: Point2D::new(stroke_width_w, INPUT_HEIGHT),
},
));
}
rects.extend(stroke_side_input_rects(x0, y, w, visible.stroke_edit_mode));
}
rects

View file

@ -471,10 +471,13 @@ pub fn action_button_rects_with_fill_picker(
} else {
0.0
};
// The stroke width moved to the mode grid below, so the hex fills the
// whole row (matches property_panel_stroke + the input-rect walker) —
// the variable button + picker anchor must use this widened hex_w.
let hex_w = usable_w - variable_w;
// Inline width only in Single mode (matches paint_stroke_main_row);
// in per-side mode the hex fills the row so the variable button +
// colour-picker anchor stay aligned.
let inline = visible.stroke_edit_mode == op_editor_core::PaddingEditMode::Single;
let width_w = if inline { 60.0 } else { 0.0 };
let width_gap = if inline { 8.0 } else { 0.0 };
let hex_w = usable_w - width_w - width_gap - variable_w;
if !visible.stroke_variable_bound {
out.push((
PropertyPanelAction::OpenColorPicker(op_editor_core::ColorTarget::Stroke),

View file

@ -29,6 +29,10 @@ struct StrokeSideGridRefs<'a, 'b> {
}
pub(crate) fn stroke_section_body_height(mode: PaddingEditMode) -> f32 {
// Single mode shows just the inline main row — no per-side grid below.
if mode == PaddingEditMode::Single {
return INPUT_HEIGHT + SECTION_TRAILING_GAP;
}
let rows = if mode == PaddingEditMode::Individual {
2.0
} else {
@ -62,13 +66,8 @@ pub(crate) fn stroke_side_input_rects(
size: Point2D::new(half_w, INPUT_HEIGHT),
};
match mode {
PaddingEditMode::Single => vec![(
PropertyFocus::StrokeTopWidth,
Rect {
origin: Point2D::new(x + PAD_X, grid_y),
size: Point2D::new(usable_w, INPUT_HEIGHT),
},
)],
// Single mode keeps the width inline on the main row — no grid input.
PaddingEditMode::Single => vec![],
PaddingEditMode::Axis => vec![
(PropertyFocus::StrokeRightWidth, cell(0.0, 0.0)),
(PropertyFocus::StrokeTopWidth, cell(1.0, 0.0)),
@ -229,19 +228,24 @@ pub fn paint_stroke_section(
x,
y,
width,
);
paint_stroke_side_grid(
cx,
StrokeSideGridRefs {
theme,
snapshot,
edit,
},
x,
y,
width,
mode,
);
// Per-side grid only in Axis / Individual mode; Single keeps the width
// inline on the main row above (no duplicate input).
if mode != PaddingEditMode::Single {
paint_stroke_side_grid(
cx,
StrokeSideGridRefs {
theme,
snapshot,
edit,
},
x,
y,
width,
mode,
);
}
y += stroke_section_body_height(mode);
paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP
@ -258,20 +262,25 @@ fn paint_stroke_main_row(
x: f32,
y: f32,
width: f32,
mode: PaddingEditMode,
) {
let usable_w = width - PAD_X * 2.0;
let stroke_color = snapshot.stroke_swatch_color();
let stroke_width = snapshot.stroke.map(|s| s.width).unwrap_or(0.0);
// Inline width only in Single mode; Axis / Individual put the widths in
// the per-side grid below, so the main row is colour-only there and the
// hex fills the freed space.
let inline_width = mode == PaddingEditMode::Single;
let width_w = if inline_width { 60.0 } else { 0.0 };
let width_gap = if inline_width { 8.0 } else { 0.0 };
let variable_w = if show_variable_button {
COLOR_VARIABLE_BUTTON_W + COLOR_VARIABLE_GAP
} else {
0.0
};
// The stroke WIDTH lives in the mode grid below (Single = 1 uniform,
// Axis = 2, Individual = 4) — the main row carries only the colour, so
// there is no duplicate width input. Let the hex fill the freed space.
let hex_rect = Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(usable_w - variable_w, INPUT_HEIGHT),
size: Point2D::new(usable_w - width_w - width_gap - variable_w, INPUT_HEIGHT),
};
let hex_focused = edit.focus == Some(PropertyFocus::StrokeHex);
cx.backend
@ -300,6 +309,27 @@ fn paint_stroke_main_row(
stroke_variable_ref.is_some(),
);
}
// Inline stroke-width input — Single mode only (Axis/Individual put the
// widths in the per-side grid). PropertyFocus::StrokeWidth edit path.
if inline_width {
let width_rect = Rect {
origin: Point2D::new(hex_rect.origin.x + hex_rect.size.x + variable_w + 8.0, y),
size: Point2D::new(width_w, INPUT_HEIGHT),
};
let wval = format_panel_number(stroke_width);
paint_input_with_prefix_focused_state(
cx,
theme,
width_rect,
"",
edit.value_for(PropertyFocus::StrokeWidth, &wval),
edit.focus == Some(PropertyFocus::StrokeWidth),
edit.caret_at(PropertyFocus::StrokeWidth),
edit.select_all_at(PropertyFocus::StrokeWidth),
edit.input_at(PropertyFocus::StrokeWidth),
edit.now_ms,
);
}
}
fn paint_stroke_hex_text(