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:
parent
ec5ba9c283
commit
00a4616b11
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
51
crates/op-editor-core/src/command_root_replace.rs
Normal file
51
crates/op-editor-core/src/command_root_replace.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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![]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue