feat(canvas): add cross-container drag controls
This commit is contained in:
parent
fcf5ddf7a4
commit
8d1e2e5dd7
|
|
@ -1,10 +1,10 @@
|
|||
//! Canvas-drag mutators: handle-resize (with descendant scaling) +
|
||||
//! drag-end auto-layout reorder / reparent-to-page-root.
|
||||
//! drag-end auto-layout reorder / cross-container reparenting.
|
||||
//!
|
||||
//! Ports the TS behavior from `skia-interaction.ts` (`handleResizeMove`
|
||||
//! / `handleDragEnd`), `drag-reparent-policy.ts`, and pen-core
|
||||
//! `tree-utils.ts::scaleChildrenInPlace`. Split out of `mutators.rs`
|
||||
//! to keep that file under the 800-line ceiling.
|
||||
//! / `handleDragEnd`) and pen-core `tree-utils.ts::scaleChildrenInPlace`.
|
||||
//! Split out of `mutators.rs` to keep that file under the 800-line
|
||||
//! ceiling.
|
||||
|
||||
use crate::geometry::{own_bounds, DocRect};
|
||||
use crate::node_id::NodeId;
|
||||
|
|
@ -22,6 +22,23 @@ pub enum FlexDirection {
|
|||
Horizontal,
|
||||
}
|
||||
|
||||
/// Canonical drop destination for canvas node dragging. The host
|
||||
/// resolves hit-testing and absolute container bounds; core owns the
|
||||
/// document-tree mutation and coordinate conversion.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DragDropTarget {
|
||||
/// Insert as a top-level node in the active page.
|
||||
PageRoot { index: usize },
|
||||
/// Insert into a container. `parent_abs_*` are the target
|
||||
/// container's absolute document-space origin read from layout.
|
||||
Container {
|
||||
parent_id: NodeId,
|
||||
parent_abs_x: f64,
|
||||
parent_abs_y: f64,
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// The container's explicit auto-layout direction, or `None` for
|
||||
/// free-layout containers and leaf nodes.
|
||||
pub fn auto_layout_direction(node: &PenNode) -> Option<FlexDirection> {
|
||||
|
|
@ -38,23 +55,12 @@ pub fn auto_layout_direction(node: &PenNode) -> Option<FlexDirection> {
|
|||
}
|
||||
}
|
||||
|
||||
/// TS `drag-reparent-policy.ts`: auto-reparenting a dragged child out
|
||||
/// of its parent is surprising for frame/shape-style nodes — users
|
||||
/// expect those to keep their parent while repositioning. Primitive
|
||||
/// content nodes (text / image / icon / input) keep the legacy
|
||||
/// "drag out to detach" behavior.
|
||||
pub fn should_auto_reparent_outside_parent(node: &PenNode) -> bool {
|
||||
!matches!(
|
||||
node,
|
||||
PenNode::Frame(_)
|
||||
| PenNode::Group(_)
|
||||
| PenNode::Rectangle(_)
|
||||
| PenNode::Ellipse(_)
|
||||
| PenNode::Line(_)
|
||||
| PenNode::Polygon(_)
|
||||
| PenNode::Path(_)
|
||||
| PenNode::Ref(_)
|
||||
)
|
||||
/// Current canvas drag semantics allow any editable dragged subtree to
|
||||
/// leave its parent. The editability and cycle checks are enforced by
|
||||
/// `move_node_to_drop_target`; this helper is kept for older callers
|
||||
/// that still ask the policy question by node type.
|
||||
pub fn should_auto_reparent_outside_parent(_node: &PenNode) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Immediate parent id of `target` anywhere in the forest, or `None`
|
||||
|
|
@ -182,6 +188,44 @@ impl EditorState {
|
|||
cur != idx
|
||||
}
|
||||
|
||||
/// Move the single selected child one slot along its parent
|
||||
/// auto-layout axis. Non-flow selections return false so callers
|
||||
/// can fall back to normal pixel nudging.
|
||||
pub fn move_selected_in_layout_direction(&mut self, dx: f64, dy: f64) -> bool {
|
||||
if self.selection_count() != 1 {
|
||||
return false;
|
||||
}
|
||||
let selected = self.selection.anchor.clone();
|
||||
if !selected.is_real() || !self.is_editable(&selected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let children = self.active_children();
|
||||
let Some((Some(parent_id), current_index)) =
|
||||
walkers::find_parent_and_index(children, &selected)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(parent) = walkers::find_node(children, &parent_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(direction) = auto_layout_direction(parent) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let target_index = match direction {
|
||||
FlexDirection::Vertical if dy < 0.0 => current_index.checked_sub(1),
|
||||
FlexDirection::Vertical if dy > 0.0 => Some(current_index + 1),
|
||||
FlexDirection::Horizontal if dx < 0.0 => current_index.checked_sub(1),
|
||||
FlexDirection::Horizontal if dx > 0.0 => Some(current_index + 1),
|
||||
_ => None,
|
||||
};
|
||||
let Some(target_index) = target_index else {
|
||||
return false;
|
||||
};
|
||||
self.reorder_child_to_index(&parent_id, &selected, target_index)
|
||||
}
|
||||
|
||||
/// Detach `id` from its parent and re-insert it as the FIRST
|
||||
/// top-level child of the active page, preserving its visual
|
||||
/// position via the absolute `(abs_x, abs_y)` the caller read off
|
||||
|
|
@ -207,4 +251,97 @@ impl EditorState {
|
|||
children.insert(0, node);
|
||||
true
|
||||
}
|
||||
|
||||
/// Move a dragged node to a resolved canvas drop target,
|
||||
/// preserving the visual origin supplied by the host.
|
||||
///
|
||||
/// Coordinate semantics:
|
||||
/// - Page root: node `x`/`y` become the dropped absolute origin.
|
||||
/// - Free container: node `x`/`y` become relative to the target
|
||||
/// container origin.
|
||||
/// - Flex container: node enters flow at `index`, so authored
|
||||
/// `x`/`y` are cleared.
|
||||
///
|
||||
/// When the node changes parent, the resolved drag bounds are
|
||||
/// frozen as literal `width` / `height` so keyword sizing such as
|
||||
/// `fill_container` keeps its visual size after leaving the old
|
||||
/// container.
|
||||
pub fn move_node_to_drop_target(
|
||||
&mut self,
|
||||
id: &NodeId,
|
||||
target: DragDropTarget,
|
||||
abs_x: f64,
|
||||
abs_y: f64,
|
||||
abs_w: f64,
|
||||
abs_h: f64,
|
||||
) -> bool {
|
||||
if !id.is_real() || !self.is_subtree_editable(id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let source_parent = parent_of(self.active_children(), id);
|
||||
let (target_parent, target_index, target_flex, target_abs) = {
|
||||
let children = self.active_children();
|
||||
let Some(source) = walkers::find_node(children, id) else {
|
||||
return false;
|
||||
};
|
||||
match &target {
|
||||
DragDropTarget::PageRoot { index } => (None, *index, false, (0.0, 0.0)),
|
||||
DragDropTarget::Container {
|
||||
parent_id,
|
||||
parent_abs_x,
|
||||
parent_abs_y,
|
||||
index,
|
||||
} => {
|
||||
if parent_id == id || walkers::descendant_contains(source, parent_id) {
|
||||
return false;
|
||||
}
|
||||
let Some(parent) = walkers::find_node(children, parent_id) else {
|
||||
return false;
|
||||
};
|
||||
if parent.children().is_none() {
|
||||
return false;
|
||||
}
|
||||
(
|
||||
Some(parent_id.clone()),
|
||||
*index,
|
||||
auto_layout_direction(parent).is_some(),
|
||||
(*parent_abs_x, *parent_abs_y),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let Some(mut node) = walkers::extract_node(self.active_children_mut(), id) else {
|
||||
return false;
|
||||
};
|
||||
if source_parent != target_parent {
|
||||
if abs_w.is_finite() && abs_w > 0.0 {
|
||||
node.set_width_px(abs_w);
|
||||
}
|
||||
if abs_h.is_finite() && abs_h > 0.0 {
|
||||
node.set_height_px(abs_h);
|
||||
}
|
||||
}
|
||||
{
|
||||
let base = node.base_mut();
|
||||
if target_flex {
|
||||
base.x = None;
|
||||
base.y = None;
|
||||
} else if target_parent.is_some() {
|
||||
base.x = Some(abs_x - target_abs.0);
|
||||
base.y = Some(abs_y - target_abs.1);
|
||||
} else {
|
||||
base.x = Some(abs_x);
|
||||
base.y = Some(abs_y);
|
||||
}
|
||||
}
|
||||
|
||||
walkers::insert_into_parent(
|
||||
self.active_children_mut(),
|
||||
target_parent.as_ref(),
|
||||
Some(target_index),
|
||||
node,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
#![cfg(test)]
|
||||
|
||||
use crate::drag_mutators::{
|
||||
auto_layout_direction, parent_of, should_auto_reparent_outside_parent, FlexDirection,
|
||||
auto_layout_direction, parent_of, should_auto_reparent_outside_parent, DragDropTarget,
|
||||
FlexDirection,
|
||||
};
|
||||
use crate::geometry::DocRect;
|
||||
use crate::node_id::NodeId;
|
||||
|
|
@ -213,6 +214,73 @@ fn reorder_child_to_index_clamps_to_tail_and_reports_noop() {
|
|||
assert_eq!(order, vec!["b", "a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_arrow_down_moves_selection_forward_in_vertical_container() {
|
||||
let f = flex_frame(
|
||||
"f1",
|
||||
"Stack",
|
||||
0.0,
|
||||
0.0,
|
||||
200.0,
|
||||
300.0,
|
||||
vec![
|
||||
flow_rect("a", "A", 100.0, 40.0),
|
||||
flow_rect("b", "B", 100.0, 40.0),
|
||||
flow_rect("c", "C", 100.0, 40.0),
|
||||
],
|
||||
);
|
||||
let mut s = state_with(vec![f]);
|
||||
s.set_single_selection(NodeId::new("b"));
|
||||
|
||||
assert!(s.move_selected_in_layout_direction(0.0, 1.0));
|
||||
|
||||
let parent = find_node(s.active_children(), &NodeId::new("f1")).unwrap();
|
||||
let order: Vec<&str> = parent
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.id_str())
|
||||
.collect();
|
||||
assert_eq!(order, vec!["a", "c", "b"]);
|
||||
assert_eq!(s.selection.anchor, NodeId::new("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_arrow_left_moves_selection_backward_in_horizontal_container() {
|
||||
use jian_ops_schema::node::container::LayoutMode;
|
||||
|
||||
let mut f = flex_frame(
|
||||
"f1",
|
||||
"Row",
|
||||
0.0,
|
||||
0.0,
|
||||
300.0,
|
||||
100.0,
|
||||
vec![
|
||||
flow_rect("a", "A", 80.0, 40.0),
|
||||
flow_rect("b", "B", 80.0, 40.0),
|
||||
flow_rect("c", "C", 80.0, 40.0),
|
||||
],
|
||||
);
|
||||
if let PenNode::Frame(frame) = &mut f {
|
||||
frame.container.layout = Some(LayoutMode::Horizontal);
|
||||
}
|
||||
let mut s = state_with(vec![f]);
|
||||
s.set_single_selection(NodeId::new("b"));
|
||||
|
||||
assert!(s.move_selected_in_layout_direction(-1.0, 0.0));
|
||||
|
||||
let parent = find_node(s.active_children(), &NodeId::new("f1")).unwrap();
|
||||
let order: Vec<&str> = parent
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.id_str())
|
||||
.collect();
|
||||
assert_eq!(order, vec!["b", "a", "c"]);
|
||||
assert_eq!(s.selection.anchor, NodeId::new("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_to_page_root_preserves_visual_position() {
|
||||
let mut s = nested_state();
|
||||
|
|
@ -234,19 +302,176 @@ fn reparent_to_page_root_is_noop_for_top_level_nodes() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_policy_matches_ts_drag_reparent_policy() {
|
||||
// Frame/shape-style nodes keep their parent; content primitives
|
||||
// (text, …) detach.
|
||||
fn reparent_policy_allows_shape_container_and_content_nodes() {
|
||||
let r = rect("r", "R", 0.0, 0.0, 10.0, 10.0);
|
||||
let t = text("t", "T", 0.0, 0.0, 10.0, 10.0, "hi");
|
||||
let f = frame("f", "F", 0.0, 0.0, 10.0, 10.0, vec![]);
|
||||
let g = group("g", "G", vec![]);
|
||||
assert!(!should_auto_reparent_outside_parent(&r));
|
||||
assert!(!should_auto_reparent_outside_parent(&f));
|
||||
assert!(!should_auto_reparent_outside_parent(&g));
|
||||
assert!(should_auto_reparent_outside_parent(&r));
|
||||
assert!(should_auto_reparent_outside_parent(&f));
|
||||
assert!(should_auto_reparent_outside_parent(&g));
|
||||
assert!(should_auto_reparent_outside_parent(&t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_into_free_container_preserves_visual_position_as_relative_xy() {
|
||||
let src_child = rect("box", "Box", 20.0, 30.0, 50.0, 40.0);
|
||||
let src = frame("src", "Source", 100.0, 100.0, 200.0, 200.0, vec![src_child]);
|
||||
let target = frame("target", "Target", 400.0, 200.0, 240.0, 180.0, vec![]);
|
||||
let mut s = state_with(vec![src, target]);
|
||||
|
||||
assert!(s.move_node_to_drop_target(
|
||||
&NodeId::new("box"),
|
||||
DragDropTarget::Container {
|
||||
parent_id: NodeId::new("target"),
|
||||
parent_abs_x: 400.0,
|
||||
parent_abs_y: 200.0,
|
||||
index: 0,
|
||||
},
|
||||
450.0,
|
||||
260.0,
|
||||
50.0,
|
||||
40.0,
|
||||
));
|
||||
|
||||
let src = find_node(s.active_children(), &NodeId::new("src")).unwrap();
|
||||
assert!(src.children().unwrap().is_empty());
|
||||
let target = find_node(s.active_children(), &NodeId::new("target")).unwrap();
|
||||
let moved = target
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|node| node.id_str() == "box")
|
||||
.expect("box moved into target");
|
||||
assert_eq!(moved.base().x, Some(50.0));
|
||||
assert_eq!(moved.base().y, Some(60.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_to_page_root_makes_nested_node_a_root_at_absolute_position() {
|
||||
let src_child = rect("box", "Box", 20.0, 30.0, 50.0, 40.0);
|
||||
let src = frame("src", "Source", 100.0, 100.0, 200.0, 200.0, vec![src_child]);
|
||||
let target = frame("target", "Target", 400.0, 200.0, 240.0, 180.0, vec![]);
|
||||
let mut s = state_with(vec![src, target]);
|
||||
|
||||
assert!(s.move_node_to_drop_target(
|
||||
&NodeId::new("box"),
|
||||
DragDropTarget::PageRoot { index: 1 },
|
||||
700.0,
|
||||
80.0,
|
||||
50.0,
|
||||
40.0,
|
||||
));
|
||||
|
||||
let ids: Vec<&str> = s
|
||||
.active_children()
|
||||
.iter()
|
||||
.map(|node| node.id_str())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["src", "box", "target"]);
|
||||
let moved = find_node(s.active_children(), &NodeId::new("box")).unwrap();
|
||||
assert_eq!(moved.base().x, Some(700.0));
|
||||
assert_eq!(moved.base().y, Some(80.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_root_into_free_container_preserves_visual_position() {
|
||||
let root = rect("box", "Box", 100.0, 120.0, 50.0, 40.0);
|
||||
let target = frame("target", "Target", 400.0, 200.0, 240.0, 180.0, vec![]);
|
||||
let mut s = state_with(vec![root, target]);
|
||||
|
||||
assert!(s.move_node_to_drop_target(
|
||||
&NodeId::new("box"),
|
||||
DragDropTarget::Container {
|
||||
parent_id: NodeId::new("target"),
|
||||
parent_abs_x: 400.0,
|
||||
parent_abs_y: 200.0,
|
||||
index: 0,
|
||||
},
|
||||
440.0,
|
||||
230.0,
|
||||
50.0,
|
||||
40.0,
|
||||
));
|
||||
|
||||
let root_ids: Vec<&str> = s
|
||||
.active_children()
|
||||
.iter()
|
||||
.map(|node| node.id_str())
|
||||
.collect();
|
||||
assert_eq!(root_ids, vec!["target"]);
|
||||
let target = find_node(s.active_children(), &NodeId::new("target")).unwrap();
|
||||
let moved = &target.children().unwrap()[0];
|
||||
assert_eq!(moved.id_str(), "box");
|
||||
assert_eq!(moved.base().x, Some(40.0));
|
||||
assert_eq!(moved.base().y, Some(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_root_into_flex_container_clears_xy_and_inserts_at_index() {
|
||||
let root = rect("box", "Box", 100.0, 120.0, 50.0, 40.0);
|
||||
let target = flex_frame(
|
||||
"stack",
|
||||
"Stack",
|
||||
400.0,
|
||||
200.0,
|
||||
240.0,
|
||||
180.0,
|
||||
vec![
|
||||
flow_rect("a", "A", 100.0, 40.0),
|
||||
flow_rect("b", "B", 100.0, 40.0),
|
||||
],
|
||||
);
|
||||
let mut s = state_with(vec![root, target]);
|
||||
|
||||
assert!(s.move_node_to_drop_target(
|
||||
&NodeId::new("box"),
|
||||
DragDropTarget::Container {
|
||||
parent_id: NodeId::new("stack"),
|
||||
parent_abs_x: 400.0,
|
||||
parent_abs_y: 200.0,
|
||||
index: 1,
|
||||
},
|
||||
430.0,
|
||||
245.0,
|
||||
50.0,
|
||||
40.0,
|
||||
));
|
||||
|
||||
let stack = find_node(s.active_children(), &NodeId::new("stack")).unwrap();
|
||||
let children = stack.children().unwrap();
|
||||
let ids: Vec<&str> = children.iter().map(|node| node.id_str()).collect();
|
||||
assert_eq!(ids, vec!["a", "box", "b"]);
|
||||
let moved = children.iter().find(|node| node.id_str() == "box").unwrap();
|
||||
assert_eq!(moved.base().x, None);
|
||||
assert_eq!(moved.base().y, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_into_own_descendant_is_rejected_without_detaching() {
|
||||
let inner = frame("inner", "Inner", 20.0, 20.0, 100.0, 100.0, vec![]);
|
||||
let root = frame("root", "Root", 100.0, 100.0, 200.0, 200.0, vec![inner]);
|
||||
let mut s = state_with(vec![root]);
|
||||
|
||||
assert!(!s.move_node_to_drop_target(
|
||||
&NodeId::new("root"),
|
||||
DragDropTarget::Container {
|
||||
parent_id: NodeId::new("inner"),
|
||||
parent_abs_x: 120.0,
|
||||
parent_abs_y: 120.0,
|
||||
index: 0,
|
||||
},
|
||||
130.0,
|
||||
130.0,
|
||||
200.0,
|
||||
200.0,
|
||||
));
|
||||
|
||||
assert_eq!(s.active_children().len(), 1);
|
||||
assert!(find_node(s.active_children(), &NodeId::new("root")).is_some());
|
||||
assert!(find_node(s.active_children(), &NodeId::new("inner")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_of_and_direction_helpers_walk_the_tree() {
|
||||
let s = nested_state();
|
||||
|
|
|
|||
|
|
@ -1242,25 +1242,26 @@ impl ApplicationHandler<DesktopEvent> for DesktopApp {
|
|||
// set_ime_cursor_area; the committed candidate lands
|
||||
// through apply_ime_commit -> apply_text.
|
||||
WindowEvent::Ime(winit::event::Ime::Preedit(text, cursor)) => {
|
||||
if self.host.apply_ime_preedit(&text, cursor) {
|
||||
if let Some(rect) = self
|
||||
.host
|
||||
.ime_anchor_rect(self.viewport_width, self.viewport_height)
|
||||
{
|
||||
if let Some(window) = self.window.as_ref() {
|
||||
let dpi = window.scale_factor();
|
||||
window.set_ime_cursor_area(
|
||||
winit::dpi::PhysicalPosition::new(
|
||||
(rect.origin.x as f64) * dpi,
|
||||
((rect.origin.y + rect.size.y) as f64) * dpi,
|
||||
),
|
||||
winit::dpi::PhysicalSize::new(
|
||||
(rect.size.x as f64) * dpi,
|
||||
(rect.size.y as f64) * dpi,
|
||||
),
|
||||
);
|
||||
}
|
||||
let changed = self.host.apply_ime_preedit(&text, cursor);
|
||||
if let Some(rect) = self
|
||||
.host
|
||||
.ime_anchor_rect(self.viewport_width, self.viewport_height)
|
||||
{
|
||||
if let Some(window) = self.window.as_ref() {
|
||||
let dpi = window.scale_factor();
|
||||
window.set_ime_cursor_area(
|
||||
winit::dpi::PhysicalPosition::new(
|
||||
(rect.origin.x as f64) * dpi,
|
||||
((rect.origin.y + rect.size.y) as f64) * dpi,
|
||||
),
|
||||
winit::dpi::PhysicalSize::new(
|
||||
(rect.size.x as f64) * dpi,
|
||||
(rect.size.y as f64) * dpi,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.request_redraw(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -1285,6 +1286,7 @@ impl ApplicationHandler<DesktopEvent> for DesktopApp {
|
|||
// mouse press can branch on shift+click for
|
||||
// multi-select.
|
||||
self.host.set_modifier_shift(self.shift_modifier);
|
||||
self.host.set_modifier_alt(self.alt_modifier);
|
||||
}
|
||||
WindowEvent::KeyboardInput {
|
||||
event:
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ impl DesktopApp {
|
|||
// the canvas text editor, then a focused property
|
||||
// input; otherwise the arrow nudges the selection.
|
||||
consumed = self.host.apply_chat_model_picker_caret(false)
|
||||
|| self.host.apply_chat_input_caret(false)
|
||||
|| self.host.apply_rename_caret(false)
|
||||
|| self.host.apply_text_edit_caret(false)
|
||||
|| self.host.apply_property_caret(false)
|
||||
|
|
@ -126,6 +127,7 @@ impl DesktopApp {
|
|||
}
|
||||
Key::Named(NamedKey::ArrowRight) if !self.zoom_modifier && !settings_focused => {
|
||||
consumed = self.host.apply_chat_model_picker_caret(true)
|
||||
|| self.host.apply_chat_input_caret(true)
|
||||
|| self.host.apply_rename_caret(true)
|
||||
|| self.host.apply_text_edit_caret(true)
|
||||
|| self.host.apply_property_caret(true)
|
||||
|
|
|
|||
|
|
@ -883,12 +883,17 @@ impl DesktopApp {
|
|||
let eui = &self.host.editor_state().editor_ui;
|
||||
eui.file_menu_open || eui.locale_picker.open || eui.shape_picker.open
|
||||
};
|
||||
let cursor_changed =
|
||||
if over_layer_panel && !self.host.layer_drag_in_progress() && !overlay_open {
|
||||
false
|
||||
} else {
|
||||
self.host.apply_cursor_move(cx, cy)
|
||||
};
|
||||
// Side-panel resize starts on the gutter but must keep receiving
|
||||
// cursor moves after the pointer crosses back into the layer rail.
|
||||
let cursor_changed = if over_layer_panel
|
||||
&& !self.host.layer_drag_in_progress()
|
||||
&& !self.host.is_resizing_panel()
|
||||
&& !overlay_open
|
||||
{
|
||||
false
|
||||
} else {
|
||||
self.host.apply_cursor_move(cx, cy)
|
||||
};
|
||||
hover_changed || cursor_changed
|
||||
} else {
|
||||
false
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
use super::*;
|
||||
use op_host_services::mcp_serve::tool_text;
|
||||
use winit::keyboard::{Key, NamedKey};
|
||||
|
||||
#[test]
|
||||
fn cursor_only_redraw_without_visible_state_change_skips_present() {
|
||||
|
|
@ -36,6 +37,25 @@ fn cursor_redraw_still_paints_when_layer_hover_changes() {
|
|||
assert!(app.prepare_redraw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_resize_drag_continues_inside_left_layer_panel() {
|
||||
let mut app = DesktopApp::new(None);
|
||||
let start_width = app.host.editor_state().editor_ui.layer_panel_width;
|
||||
let y = op_editor_ui::widgets::TOP_BAR_HEIGHT + 140.0;
|
||||
assert!(app
|
||||
.host
|
||||
.apply_press(start_width, y, app.viewport_width, app.viewport_height));
|
||||
assert!(app.host.is_resizing_panel());
|
||||
|
||||
app.pending_cursor_move = Some((start_width - 72.0, y));
|
||||
|
||||
assert!(app.drain_pending_cursor_move());
|
||||
assert!(
|
||||
app.host.editor_state().editor_ui.layer_panel_width < start_width,
|
||||
"in-flight panel resize must keep receiving cursor moves after the cursor enters the left panel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_row_input_keeps_resume_time_redraws_active() {
|
||||
// Serialize against reveal-streaming design-turn tests and start from
|
||||
|
|
@ -87,6 +107,48 @@ fn selected_count_chip_clear_click_clears_canvas_selection() {
|
|||
assert!(app.host.editor_state().selection.set.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_input_arrows_move_caret_before_insert() {
|
||||
let mut app = DesktopApp::new(None);
|
||||
app.host.editor_state_mut().chat.focused = true;
|
||||
app.host.editor_state_mut().chat.set_input_text("abcd");
|
||||
|
||||
app.handle_key_pressed(&Key::Named(NamedKey::ArrowLeft), None);
|
||||
app.handle_key_pressed(&Key::Named(NamedKey::ArrowLeft), None);
|
||||
|
||||
assert_eq!(app.host.editor_state().chat.input_caret(), 2);
|
||||
assert!(app.host.apply_text('X'));
|
||||
assert_eq!(app.host.editor_state().chat.input.text(), "abXcd");
|
||||
assert_eq!(app.host.editor_state().chat.input_caret(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_ime_anchor_tracks_input_caret() {
|
||||
let mut app = DesktopApp::new(None);
|
||||
app.host.editor_state_mut().chat.focused = true;
|
||||
app.host.editor_state_mut().chat.set_input_text("abcd");
|
||||
app.host.editor_state_mut().chat.set_input_caret(0, 0);
|
||||
let start = app
|
||||
.host
|
||||
.ime_anchor_rect(1200.0, 800.0)
|
||||
.expect("chat focus should yield ime anchor");
|
||||
|
||||
app.host.editor_state_mut().chat.set_input_caret(3, 0);
|
||||
let after_three = app
|
||||
.host
|
||||
.ime_anchor_rect(1200.0, 800.0)
|
||||
.expect("chat focus should yield ime anchor");
|
||||
|
||||
assert!(
|
||||
after_three.origin.x > start.origin.x + 12.0,
|
||||
"expected IME anchor to move with caret: start={start:?}, after={after_three:?}"
|
||||
);
|
||||
assert!(
|
||||
after_three.size.x <= 4.0,
|
||||
"IME anchor should describe the caret, not the whole input: {after_three:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_app_fits_blank_frame_like_ts_canvas_init() {
|
||||
let app = DesktopApp::new(None);
|
||||
|
|
|
|||
|
|
@ -255,6 +255,10 @@ pub struct WidgetHostNative {
|
|||
/// cursor anchor so each `apply_cursor_move` translates the
|
||||
/// selected node by the delta.
|
||||
pub(in crate::widget_host) node_drag: Option<NodeDragState>,
|
||||
/// Original selected ids for an active Option-drag clone move.
|
||||
/// Drop hit-testing skips these so a fresh clone does not
|
||||
/// immediately reparent back into the source it overlaps.
|
||||
pub(in crate::widget_host) option_drag_source_ids: Vec<op_editor_core::NodeId>,
|
||||
/// Active handle-drag — set when the user pressed on one of
|
||||
/// the 8 selection handles. Carries the start screen anchor +
|
||||
/// the original bounds so each move computes a fresh
|
||||
|
|
@ -302,6 +306,9 @@ pub struct WidgetHostNative {
|
|||
/// this via `set_modifier_shift` on every modifier change.
|
||||
/// Drives shift+click multi-select in `apply_press`.
|
||||
pub(in crate::widget_host) shift_held: bool,
|
||||
/// Whether Alt/Option is currently held. Node dragging uses this
|
||||
/// to duplicate the current selection before moving it.
|
||||
pub(in crate::widget_host) alt_held: bool,
|
||||
/// Last viewport size seen by paint/press. Used by handlers
|
||||
/// that don't receive viewport dims (e.g. apply_cursor_move
|
||||
/// driving the color-picker drag).
|
||||
|
|
@ -601,6 +608,7 @@ impl WidgetHostNative {
|
|||
panel_resize: None,
|
||||
variables_resize: None,
|
||||
node_drag: None,
|
||||
option_drag_source_ids: Vec::new(),
|
||||
path_anchor_drag: None,
|
||||
arc_handle_drag: None,
|
||||
handle_drag: None,
|
||||
|
|
@ -611,6 +619,7 @@ impl WidgetHostNative {
|
|||
next_node_id: 100,
|
||||
now_ms: 0,
|
||||
shift_held: false,
|
||||
alt_held: false,
|
||||
last_viewport_w: 0.0,
|
||||
last_viewport_h: 0.0,
|
||||
preview: None,
|
||||
|
|
@ -626,6 +635,10 @@ impl WidgetHostNative {
|
|||
self.shift_held = held;
|
||||
}
|
||||
|
||||
pub fn set_modifier_alt(&mut self, held: bool) {
|
||||
self.alt_held = held;
|
||||
}
|
||||
|
||||
/// Push the host's monotonic millisecond timestamp into the
|
||||
/// host. Drives caret blink + any future time-based
|
||||
/// animations via `jian_core::anim`. Also forwarded to the live
|
||||
|
|
|
|||
|
|
@ -1,33 +1,35 @@
|
|||
//! Canvas selection semantics (enter-group on double-click) + the
|
||||
//! node-drag release commit (auto-layout reorder / reparent-to-root).
|
||||
//! node-drag release commit (auto-layout reorder / cross-container
|
||||
//! reparenting).
|
||||
//!
|
||||
//! TS sources: `skia-interaction.ts:1182-1256` (`handleDragEnd`),
|
||||
//! `:1262-1294` (double-click enter-group) and
|
||||
//! `drag-reparent-policy.ts`. The selection-resolution rules live in
|
||||
//! `op_editor_core::selection_resolve`; this module is the host glue
|
||||
//! that reads the layout scene for absolute bounds.
|
||||
|
||||
use super::{NodeDragState, WidgetHostNative};
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use op_editor_core::drag_mutators::{
|
||||
auto_layout_direction, parent_of, should_auto_reparent_outside_parent, FlexDirection,
|
||||
auto_layout_direction, parent_of, DragDropTarget, FlexDirection,
|
||||
};
|
||||
use op_editor_core::editor_ui_state::{CanvasDropIndicator, CanvasOverlayLine, CanvasOverlayRect};
|
||||
use op_editor_core::{NodeId, PenNodeExt};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
/// Read-phase summary of one dragged node — collected before any
|
||||
/// mutation so no document / scene borrow survives into the mutators.
|
||||
struct DragCommitPlan {
|
||||
target: Option<DragDropTarget>,
|
||||
dropped_bounds: Rect,
|
||||
indicator: Option<CanvasDropIndicator>,
|
||||
}
|
||||
|
||||
struct ContainerDropCandidate {
|
||||
parent_id: NodeId,
|
||||
/// `Some(direction)` when the parent is an auto-layout container.
|
||||
bounds: Rect,
|
||||
flex: Option<FlexDirection>,
|
||||
/// Dropped absolute bounds `(x, y, w, h)` of the dragged node.
|
||||
bounds: (f32, f32, f32, f32),
|
||||
/// Whether the node sits fully outside its parent's bounds AND
|
||||
/// the reparent policy allows detaching it.
|
||||
reparent_to_root: bool,
|
||||
/// Sibling ids (dragged node excluded) in document order, with
|
||||
/// each sibling's main-axis midpoint from the layout scene.
|
||||
sibling_mids: Vec<f32>,
|
||||
index: usize,
|
||||
insertion: Option<CanvasOverlayLine>,
|
||||
}
|
||||
|
||||
impl WidgetHostNative {
|
||||
|
|
@ -44,6 +46,7 @@ impl WidgetHostNative {
|
|||
x: f32,
|
||||
y: f32,
|
||||
text_edit_was_active: bool,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
use op_editor_core::selection_resolve::{
|
||||
resolve_canvas_selection_target, SelectionResolution,
|
||||
|
|
@ -60,7 +63,7 @@ impl WidgetHostNative {
|
|||
// TS dblclick order (skia-interaction.ts:1279-1296):
|
||||
// entering a selected frame/group wins over the
|
||||
// text-edit fallback.
|
||||
if self.try_enter_selected_container_on_double_click(&ec_id) {
|
||||
if self.try_enter_selected_container_on_double_click(&ec_id, viewport_height) {
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.start_text_edit(ec_id.clone()) {
|
||||
|
|
@ -84,6 +87,7 @@ impl WidgetHostNative {
|
|||
total_dx: 0.0,
|
||||
total_dy: 0.0,
|
||||
};
|
||||
self.option_drag_source_ids.clear();
|
||||
if self.shift_held {
|
||||
// Shift+click toggles set membership of the resolved
|
||||
// target; a child of a selected node keeps the set and
|
||||
|
|
@ -98,6 +102,7 @@ impl WidgetHostNative {
|
|||
} else {
|
||||
self.node_drag = Some(fresh_drag);
|
||||
}
|
||||
self.scroll_layer_panel_selection_into_view(viewport_height);
|
||||
return true;
|
||||
}
|
||||
// Plain click: keep a multi-set when clicking inside it (TS
|
||||
|
|
@ -109,6 +114,7 @@ impl WidgetHostNative {
|
|||
}
|
||||
}
|
||||
self.editor_state.sync_entered_container_with_selection();
|
||||
self.scroll_layer_panel_selection_into_view(viewport_height);
|
||||
self.editor_state.commit_history();
|
||||
self.node_drag = Some(fresh_drag);
|
||||
true
|
||||
|
|
@ -123,6 +129,7 @@ impl WidgetHostNative {
|
|||
pub(in crate::widget_host) fn try_enter_selected_container_on_double_click(
|
||||
&mut self,
|
||||
deepest: &NodeId,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
if self.editor_state.selection_count() != 1 {
|
||||
return false;
|
||||
|
|
@ -148,16 +155,29 @@ impl WidgetHostNative {
|
|||
}
|
||||
self.editor_state.set_single_selection(deepest.clone());
|
||||
self.editor_state.sync_entered_container_with_selection();
|
||||
self.scroll_layer_panel_selection_into_view(viewport_height);
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
|
||||
/// Node-drag release commit (TS `handleDragEnd`): a node dropped
|
||||
/// fully outside its parent reparents to the page root preserving
|
||||
/// its visual position (content primitives only — see
|
||||
/// `drag-reparent-policy.ts`); a child dropped within an
|
||||
/// auto-layout parent re-inserts at the midpoint-derived sibling
|
||||
/// index. Free-layout children were already translated live.
|
||||
pub(in crate::widget_host) fn update_node_drag_preview(&mut self, drag: &NodeDragState) {
|
||||
let id = self.editor_state.selection.anchor.clone();
|
||||
let next = if id.is_real() {
|
||||
self.plan_drag_commit(&id, drag)
|
||||
.and_then(|plan| plan.indicator)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.editor_state.editor_ui.canvas_drop_indicator != next {
|
||||
self.editor_state.editor_ui.canvas_drop_indicator = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Node-drag release commit: a node dropped into another container
|
||||
/// reparents there; a node dropped outside every container becomes
|
||||
/// a page root; a child dropped within an auto-layout parent
|
||||
/// re-inserts at the midpoint-derived sibling index. Free-layout
|
||||
/// children were already translated live.
|
||||
pub(in crate::widget_host) fn commit_node_drag(&mut self, drag: &NodeDragState) -> bool {
|
||||
if !drag.moved {
|
||||
return false;
|
||||
|
|
@ -182,84 +202,264 @@ impl WidgetHostNative {
|
|||
/// Read phase — gather everything the commit needs as owned data.
|
||||
fn plan_drag_commit(&self, id: &NodeId, drag: &NodeDragState) -> Option<DragCommitPlan> {
|
||||
let children = self.editor_state.active_children();
|
||||
// Top-level nodes have no reorder / reparent policy to run.
|
||||
let parent_id = parent_of(children, id)?;
|
||||
let parent = op_editor_core::walkers::find_node(children, &parent_id)?;
|
||||
let flex = auto_layout_direction(parent);
|
||||
let current_parent = parent_of(children, id);
|
||||
let current_parent_flex = current_parent
|
||||
.as_ref()
|
||||
.and_then(|parent_id| op_editor_core::walkers::find_node(children, parent_id))
|
||||
.and_then(auto_layout_direction);
|
||||
let page = self.layout_scene.active_page()?;
|
||||
let node_scene = page.find(id.as_str())?;
|
||||
let mut nb = node_scene.aggregate_bounds();
|
||||
if flex.is_some() {
|
||||
if current_parent_flex.is_some() {
|
||||
// Flex children never doc-translate during the drag — the
|
||||
// accumulated cursor delta is where the user dropped them.
|
||||
nb.origin.x += drag.total_dx as f32;
|
||||
nb.origin.y += drag.total_dy as f32;
|
||||
}
|
||||
let pb = page.find(parent_id.as_str())?.aggregate_bounds();
|
||||
let outside = nb.origin.x + nb.size.x <= pb.origin.x
|
||||
|| nb.origin.x >= pb.origin.x + pb.size.x
|
||||
|| nb.origin.y + nb.size.y <= pb.origin.y
|
||||
|| nb.origin.y >= pb.origin.y + pb.size.y;
|
||||
let node_ref = op_editor_core::walkers::find_node(children, id)?;
|
||||
let reparent_to_root = outside && should_auto_reparent_outside_parent(node_ref);
|
||||
// Sibling main-axis midpoints in document order (dragged node
|
||||
// excluded) — TS treats a missing render node as midpoint 0.
|
||||
let vertical = matches!(flex, Some(FlexDirection::Vertical));
|
||||
let sibling_mids = parent
|
||||
.children()
|
||||
.map(|siblings| {
|
||||
siblings
|
||||
.iter()
|
||||
.filter(|sib| sib.id_str() != id.as_str())
|
||||
.map(|sib| {
|
||||
page.find(sib.id_str())
|
||||
.map(|sn| {
|
||||
let b = sn.aggregate_bounds();
|
||||
if vertical {
|
||||
b.origin.y + b.size.y / 2.0
|
||||
} else {
|
||||
b.origin.x + b.size.x / 2.0
|
||||
}
|
||||
})
|
||||
.unwrap_or(0.0)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let center = Point2D::new(nb.origin.x + nb.size.x / 2.0, nb.origin.y + nb.size.y / 2.0);
|
||||
let candidate = self.container_drop_candidate(id, center, nb);
|
||||
let mut indicator = None;
|
||||
let target = if let Some(candidate) = candidate {
|
||||
let same_parent = current_parent.as_ref() == Some(&candidate.parent_id);
|
||||
if same_parent && candidate.flex.is_none() {
|
||||
None
|
||||
} else {
|
||||
indicator = Some(CanvasDropIndicator {
|
||||
ghost: overlay_rect(nb),
|
||||
target: Some(overlay_rect(candidate.bounds)),
|
||||
insertion: candidate.insertion,
|
||||
});
|
||||
Some(DragDropTarget::Container {
|
||||
parent_id: candidate.parent_id,
|
||||
parent_abs_x: candidate.bounds.origin.x as f64,
|
||||
parent_abs_y: candidate.bounds.origin.y as f64,
|
||||
index: candidate.index,
|
||||
})
|
||||
}
|
||||
} else if current_parent.is_some() {
|
||||
indicator = Some(CanvasDropIndicator {
|
||||
ghost: overlay_rect(nb),
|
||||
target: None,
|
||||
insertion: None,
|
||||
});
|
||||
Some(DragDropTarget::PageRoot { index: 0 })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(DragCommitPlan {
|
||||
parent_id,
|
||||
flex,
|
||||
bounds: (nb.origin.x, nb.origin.y, nb.size.x, nb.size.y),
|
||||
reparent_to_root,
|
||||
sibling_mids,
|
||||
target,
|
||||
dropped_bounds: nb,
|
||||
indicator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mutation phase — apply the planned reparent / reorder.
|
||||
fn apply_drag_commit(&mut self, id: &NodeId, plan: DragCommitPlan) -> bool {
|
||||
let (bx, by, bw, bh) = plan.bounds;
|
||||
if plan.reparent_to_root {
|
||||
return self
|
||||
.editor_state
|
||||
.reparent_to_page_root(id, bx as f64, by as f64);
|
||||
}
|
||||
let Some(dir) = plan.flex else {
|
||||
// Free-layout child inside its parent: the live translate
|
||||
// already committed the move.
|
||||
let Some(target) = plan.target else {
|
||||
return false;
|
||||
};
|
||||
let drag_mid = match dir {
|
||||
FlexDirection::Vertical => by + bh / 2.0,
|
||||
FlexDirection::Horizontal => bx + bw / 2.0,
|
||||
let bounds = plan.dropped_bounds;
|
||||
self.editor_state.move_node_to_drop_target(
|
||||
id,
|
||||
target,
|
||||
bounds.origin.x as f64,
|
||||
bounds.origin.y as f64,
|
||||
bounds.size.x as f64,
|
||||
bounds.size.y as f64,
|
||||
)
|
||||
}
|
||||
|
||||
fn container_drop_candidate(
|
||||
&self,
|
||||
dragged_id: &NodeId,
|
||||
point: Point2D,
|
||||
dragged_bounds: Rect,
|
||||
) -> Option<ContainerDropCandidate> {
|
||||
let children = self.editor_state.active_children();
|
||||
let source = op_editor_core::walkers::find_node(children, dragged_id)?;
|
||||
let page = self.layout_scene.active_page()?;
|
||||
deepest_container_at(children, source, point, page, &self.option_drag_source_ids).map(
|
||||
|(parent_id, bounds, flex)| {
|
||||
let (index, insertion) = if let Some(dir) = flex {
|
||||
flex_insert_preview(children, page, &parent_id, dragged_id, dragged_bounds, dir)
|
||||
} else {
|
||||
(0, None)
|
||||
};
|
||||
ContainerDropCandidate {
|
||||
parent_id,
|
||||
bounds,
|
||||
flex,
|
||||
index,
|
||||
insertion,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn deepest_container_at(
|
||||
nodes: &[PenNode],
|
||||
source: &PenNode,
|
||||
point: Point2D,
|
||||
page: &op_editor_ui::layout_scene::ScenePage,
|
||||
excluded_ids: &[NodeId],
|
||||
) -> Option<(NodeId, Rect, Option<FlexDirection>)> {
|
||||
let mut hit = None;
|
||||
for node in nodes {
|
||||
if node.children().is_none() {
|
||||
continue;
|
||||
}
|
||||
let node_id = NodeId::new(node.id_str());
|
||||
if excluded_ids.contains(&node_id) {
|
||||
continue;
|
||||
}
|
||||
if op_editor_core::walkers::descendant_contains(source, &node_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(scene) = page.find(node.id_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut new_index = plan.sibling_mids.len();
|
||||
for (i, sib_mid) in plan.sibling_mids.iter().enumerate() {
|
||||
if drag_mid < *sib_mid {
|
||||
new_index = i;
|
||||
let bounds = scene.bounds;
|
||||
if !rect_contains(bounds, point) {
|
||||
continue;
|
||||
}
|
||||
hit = Some((node_id, bounds, auto_layout_direction(node)));
|
||||
if let Some(children) = node.children() {
|
||||
if let Some(deeper) = deepest_container_at(children, source, point, page, excluded_ids)
|
||||
{
|
||||
hit = Some(deeper);
|
||||
}
|
||||
}
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
fn flex_insert_preview(
|
||||
nodes: &[PenNode],
|
||||
page: &op_editor_ui::layout_scene::ScenePage,
|
||||
parent_id: &NodeId,
|
||||
dragged_id: &NodeId,
|
||||
dragged_bounds: Rect,
|
||||
dir: FlexDirection,
|
||||
) -> (usize, Option<CanvasOverlayLine>) {
|
||||
let Some(parent) = op_editor_core::walkers::find_node(nodes, parent_id) else {
|
||||
return (0, None);
|
||||
};
|
||||
let Some(parent_scene) = page.find(parent_id.as_str()) else {
|
||||
return (0, None);
|
||||
};
|
||||
let parent_bounds = parent_scene.bounds;
|
||||
let vertical = matches!(dir, FlexDirection::Vertical);
|
||||
let drag_mid = if vertical {
|
||||
dragged_bounds.origin.y + dragged_bounds.size.y / 2.0
|
||||
} else {
|
||||
dragged_bounds.origin.x + dragged_bounds.size.x / 2.0
|
||||
};
|
||||
let mut index = parent
|
||||
.children()
|
||||
.map(|children| {
|
||||
children
|
||||
.iter()
|
||||
.filter(|node| node.id_str() != dragged_id.as_str())
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if let Some(children) = parent.children() {
|
||||
for (i, child) in children
|
||||
.iter()
|
||||
.filter(|node| node.id_str() != dragged_id.as_str())
|
||||
.enumerate()
|
||||
{
|
||||
let Some(scene) = page.find(child.id_str()) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = scene.aggregate_bounds();
|
||||
let mid = if vertical {
|
||||
bounds.origin.y + bounds.size.y / 2.0
|
||||
} else {
|
||||
bounds.origin.x + bounds.size.x / 2.0
|
||||
};
|
||||
if drag_mid < mid {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.editor_state
|
||||
.reorder_child_to_index(&plan.parent_id, id, new_index)
|
||||
}
|
||||
let insertion = flex_insertion_line(parent, page, parent_bounds, dragged_id, index, vertical);
|
||||
(index, insertion)
|
||||
}
|
||||
|
||||
fn flex_insertion_line(
|
||||
parent: &PenNode,
|
||||
page: &op_editor_ui::layout_scene::ScenePage,
|
||||
parent_bounds: Rect,
|
||||
dragged_id: &NodeId,
|
||||
index: usize,
|
||||
vertical: bool,
|
||||
) -> Option<CanvasOverlayLine> {
|
||||
let siblings: Vec<Rect> = parent
|
||||
.children()?
|
||||
.iter()
|
||||
.filter(|node| node.id_str() != dragged_id.as_str())
|
||||
.filter_map(|node| {
|
||||
page.find(node.id_str())
|
||||
.map(|scene| scene.aggregate_bounds())
|
||||
})
|
||||
.collect();
|
||||
let inset = 8.0_f32.min(parent_bounds.size.x.max(parent_bounds.size.y) / 4.0);
|
||||
if vertical {
|
||||
let y = if siblings.is_empty() {
|
||||
parent_bounds.origin.y + parent_bounds.size.y / 2.0
|
||||
} else if index == 0 {
|
||||
siblings[0].origin.y
|
||||
} else if index >= siblings.len() {
|
||||
let last = siblings[siblings.len() - 1];
|
||||
last.origin.y + last.size.y
|
||||
} else {
|
||||
let prev = siblings[index - 1];
|
||||
let next = siblings[index];
|
||||
(prev.origin.y + prev.size.y + next.origin.y) / 2.0
|
||||
};
|
||||
Some(CanvasOverlayLine::new(
|
||||
(parent_bounds.origin.x + inset) as f64,
|
||||
y as f64,
|
||||
(parent_bounds.origin.x + parent_bounds.size.x - inset) as f64,
|
||||
y as f64,
|
||||
))
|
||||
} else {
|
||||
let x = if siblings.is_empty() {
|
||||
parent_bounds.origin.x + parent_bounds.size.x / 2.0
|
||||
} else if index == 0 {
|
||||
siblings[0].origin.x
|
||||
} else if index >= siblings.len() {
|
||||
let last = siblings[siblings.len() - 1];
|
||||
last.origin.x + last.size.x
|
||||
} else {
|
||||
let prev = siblings[index - 1];
|
||||
let next = siblings[index];
|
||||
(prev.origin.x + prev.size.x + next.origin.x) / 2.0
|
||||
};
|
||||
Some(CanvasOverlayLine::new(
|
||||
x as f64,
|
||||
(parent_bounds.origin.y + inset) as f64,
|
||||
x as f64,
|
||||
(parent_bounds.origin.y + parent_bounds.size.y - inset) as f64,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_contains(rect: Rect, point: Point2D) -> bool {
|
||||
point.x >= rect.origin.x
|
||||
&& point.x <= rect.origin.x + rect.size.x
|
||||
&& point.y >= rect.origin.y
|
||||
&& point.y <= rect.origin.y + rect.size.y
|
||||
}
|
||||
|
||||
fn overlay_rect(rect: Rect) -> CanvasOverlayRect {
|
||||
CanvasOverlayRect::new(
|
||||
rect.origin.x as f64,
|
||||
rect.origin.y as f64,
|
||||
rect.size.x as f64,
|
||||
rect.size.y as f64,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,59 @@ fn blank_canvas_press_exits_the_entered_container() {
|
|||
assert_eq!(host.editor_state().editor_ui.entered_container, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_root_frame_label_selects_that_root() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
seed(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[
|
||||
{"type":"frame","id":"music","name":"Music App Home","x":400,"y":60,"width":240,"height":200,
|
||||
"children":[]}
|
||||
]}"#,
|
||||
);
|
||||
|
||||
press_doc(&mut host, 424.0, 42.0);
|
||||
|
||||
assert_eq!(host.editor_state().selection.anchor, NodeId::new("music"));
|
||||
assert!(
|
||||
host.node_drag.is_some(),
|
||||
"label press should behave like a root press"
|
||||
);
|
||||
}
|
||||
|
||||
fn overlapping_rect_stack(count: usize) -> String {
|
||||
let children = (0..count)
|
||||
.map(|i| {
|
||||
let x = if i + 1 == count {
|
||||
400.0
|
||||
} else {
|
||||
10_000.0 + i as f32 * 100.0
|
||||
};
|
||||
format!(
|
||||
r#"{{"type":"rectangle","id":"n{i}","name":"Layer {i}","x":{x},"y":60,"width":80,"height":80}}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(r#"{{"version":"0.8.0","children":[{children}]}}"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_selection_scrolls_layer_panel_to_hidden_selected_row() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
seed(&mut host, &overlapping_rect_stack(40));
|
||||
host.editor_state_mut().editor_ui.layer_layers_scroll.offset = 0.0;
|
||||
host.mark_paint_dirty_for_test();
|
||||
|
||||
press_doc(&mut host, 440.0, 100.0);
|
||||
|
||||
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n39"));
|
||||
assert!(
|
||||
host.editor_state().editor_ui.layer_layers_scroll.offset > 0.0,
|
||||
"selecting a canvas node below the visible layer rows should reveal it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promotion_inside_entered_container_stops_at_its_child() {
|
||||
// card > inner (frame) > deep (rect): with card entered, a press
|
||||
|
|
@ -192,6 +245,17 @@ fn dragging_flex_child_reorders_at_midpoint_index_on_release() {
|
|||
// and c (176) → index 1.
|
||||
let (mx, my) = screen_at(&host, 440.0, 160.0);
|
||||
host.apply_cursor_move(mx, my);
|
||||
let preview = host
|
||||
.editor_state()
|
||||
.editor_ui
|
||||
.canvas_drop_indicator
|
||||
.as_ref()
|
||||
.expect("dragging a flex child paints a drop preview");
|
||||
assert!(preview.target.is_some());
|
||||
assert!(
|
||||
preview.insertion.is_some(),
|
||||
"same-flex reorder paints an insertion line"
|
||||
);
|
||||
// Flex child must not doc-translate during the drag.
|
||||
let a = op_editor_core::walkers::find_node(
|
||||
host.editor_state().active_children(),
|
||||
|
|
@ -200,6 +264,11 @@ fn dragging_flex_child_reorders_at_midpoint_index_on_release() {
|
|||
.expect("a present");
|
||||
assert_eq!(a.base().x, None, "no live x materialization");
|
||||
release(&mut host);
|
||||
assert!(host
|
||||
.editor_state()
|
||||
.editor_ui
|
||||
.canvas_drop_indicator
|
||||
.is_none());
|
||||
assert_eq!(child_order(&host, "stack"), vec!["b", "a", "c"]);
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +285,50 @@ fn dragging_flex_child_within_its_own_slot_keeps_order() {
|
|||
assert_eq!(child_order(&host, "stack"), vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_child_dragged_to_blank_canvas_becomes_root_at_dropped_position() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
seed(&mut host, VSTACK);
|
||||
host.editor_state_mut().editor_ui.entered_container = Some(NodeId::new("stack"));
|
||||
host.mark_paint_dirty_for_test();
|
||||
|
||||
press_doc(&mut host, 440.0, 80.0); // over `a`, abs origin 400,60
|
||||
assert_eq!(host.editor_state().selection.anchor, NodeId::new("a"));
|
||||
let (mx, my) = screen_at(&host, 760.0, 80.0); // +320 doc px, outside stack
|
||||
host.apply_cursor_move(mx, my);
|
||||
let preview = host
|
||||
.editor_state()
|
||||
.editor_ui
|
||||
.canvas_drop_indicator
|
||||
.as_ref()
|
||||
.expect("drag out paints a root-drop ghost");
|
||||
assert!(preview.target.is_none());
|
||||
assert!(preview.insertion.is_none());
|
||||
assert!((preview.ghost.x - 720.0).abs() < 1.0);
|
||||
assert!((preview.ghost.y - 60.0).abs() < 1.0);
|
||||
release(&mut host);
|
||||
assert!(host
|
||||
.editor_state()
|
||||
.editor_ui
|
||||
.canvas_drop_indicator
|
||||
.is_none());
|
||||
|
||||
let children = host.editor_state().active_children();
|
||||
assert_eq!(children[0].id_str(), "a", "flow child becomes a page root");
|
||||
let moved = op_editor_core::walkers::find_node(children, &NodeId::new("a")).unwrap();
|
||||
assert!(
|
||||
(moved.base().x.unwrap_or(0.0) - 720.0).abs() < 1.0,
|
||||
"root x should use dropped bounds, got {:?}",
|
||||
moved.base().x
|
||||
);
|
||||
assert!(
|
||||
(moved.base().y.unwrap_or(0.0) - 60.0).abs() < 1.0,
|
||||
"root y should use dropped bounds, got {:?}",
|
||||
moved.base().y
|
||||
);
|
||||
assert_eq!(child_order(&host, "stack"), vec!["b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_dragged_fully_outside_parent_reparents_to_page_root() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
|
|
@ -241,7 +354,7 @@ fn text_dragged_fully_outside_parent_reparents_to_page_root() {
|
|||
assert_eq!(
|
||||
children[0].id_str(),
|
||||
"label",
|
||||
"content primitives detach to the page root (drag-reparent-policy)"
|
||||
"nested nodes detach to the page root at the drop target"
|
||||
);
|
||||
let label = &children[0];
|
||||
assert!(
|
||||
|
|
@ -254,8 +367,7 @@ fn text_dragged_fully_outside_parent_reparents_to_page_root() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn shape_dragged_outside_parent_keeps_its_parent() {
|
||||
// TS drag-reparent-policy: frame/shape-style nodes never detach.
|
||||
fn shape_dragged_outside_parent_becomes_page_root() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
seed(
|
||||
&mut host,
|
||||
|
|
@ -274,19 +386,91 @@ fn shape_dragged_outside_parent_keeps_its_parent() {
|
|||
host.apply_cursor_move(mx, my);
|
||||
release(&mut host);
|
||||
let children = host.editor_state().active_children();
|
||||
assert_eq!(children[0].id_str(), "box", "dragged shape becomes a root");
|
||||
let card = op_editor_core::walkers::find_node(children, &NodeId::new("card")).unwrap();
|
||||
let kept: Vec<&str> = card
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.id_str())
|
||||
.collect();
|
||||
assert_eq!(kept, vec!["box"], "shape stays inside its parent");
|
||||
// The free-layout translate itself still committed.
|
||||
assert!(card.children().unwrap().is_empty());
|
||||
let boxn = op_editor_core::walkers::find_node(children, &NodeId::new("box")).unwrap();
|
||||
assert!(
|
||||
(boxn.base().x.unwrap_or(0.0) - 420.0).abs() < 1.0,
|
||||
"live translate kept; got {:?}",
|
||||
(boxn.base().x.unwrap_or(0.0) - 820.0).abs() < 1.0,
|
||||
"visual x preserved as root; got {:?}",
|
||||
boxn.base().x
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_dragged_into_sibling_frame_reparents_to_that_frame() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
seed(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[
|
||||
{"type":"frame","id":"src","name":"Source","x":400,"y":60,"width":200,"height":120,
|
||||
"children":[
|
||||
{"type":"rectangle","id":"box","name":"Box","x":20,"y":20,"width":50,"height":50}
|
||||
]},
|
||||
{"type":"frame","id":"target","name":"Target","x":700,"y":60,"width":220,"height":160,
|
||||
"children":[]}
|
||||
]}"#,
|
||||
);
|
||||
host.editor_state_mut().editor_ui.entered_container = Some(NodeId::new("src"));
|
||||
host.mark_paint_dirty_for_test();
|
||||
|
||||
press_doc(&mut host, 445.0, 105.0); // box center: abs origin 420,80
|
||||
assert_eq!(host.editor_state().selection.anchor, NodeId::new("box"));
|
||||
let (mx, my) = screen_at(&host, 760.0, 100.0); // inside `target`
|
||||
host.apply_cursor_move(mx, my);
|
||||
release(&mut host);
|
||||
|
||||
let children = host.editor_state().active_children();
|
||||
let src = op_editor_core::walkers::find_node(children, &NodeId::new("src")).unwrap();
|
||||
assert!(src.children().unwrap().is_empty());
|
||||
let target = op_editor_core::walkers::find_node(children, &NodeId::new("target")).unwrap();
|
||||
let moved = &target.children().unwrap()[0];
|
||||
assert_eq!(moved.id_str(), "box");
|
||||
assert!(
|
||||
(moved.base().x.unwrap_or(0.0) - 35.0).abs() < 1.0,
|
||||
"visual x preserved relative to target; got {:?}",
|
||||
moved.base().x
|
||||
);
|
||||
assert!(
|
||||
(moved.base().y.unwrap_or(0.0) - 15.0).abs() < 1.0,
|
||||
"visual y preserved relative to target; got {:?}",
|
||||
moved.base().y
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_shape_dragged_into_frame_becomes_that_frame_child() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
seed(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[
|
||||
{"type":"rectangle","id":"box","name":"Box","x":400,"y":80,"width":50,"height":50},
|
||||
{"type":"frame","id":"target","name":"Target","x":700,"y":60,"width":220,"height":160,
|
||||
"children":[]}
|
||||
]}"#,
|
||||
);
|
||||
host.mark_paint_dirty_for_test();
|
||||
|
||||
press_doc(&mut host, 425.0, 105.0); // box center
|
||||
assert_eq!(host.editor_state().selection.anchor, NodeId::new("box"));
|
||||
let (mx, my) = screen_at(&host, 760.0, 100.0);
|
||||
host.apply_cursor_move(mx, my);
|
||||
release(&mut host);
|
||||
|
||||
let children = host.editor_state().active_children();
|
||||
assert_eq!(children.len(), 1);
|
||||
assert_eq!(children[0].id_str(), "target");
|
||||
let target = op_editor_core::walkers::find_node(children, &NodeId::new("target")).unwrap();
|
||||
let moved = &target.children().unwrap()[0];
|
||||
assert_eq!(moved.id_str(), "box");
|
||||
assert!(
|
||||
(moved.base().x.unwrap_or(0.0) - 35.0).abs() < 1.0,
|
||||
"root visual x preserved relative to target; got {:?}",
|
||||
moved.base().x
|
||||
);
|
||||
assert!(
|
||||
(moved.base().y.unwrap_or(0.0) - 15.0).abs() < 1.0,
|
||||
"root visual y preserved relative to target; got {:?}",
|
||||
moved.base().y
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,18 @@ impl WidgetHostNative {
|
|||
return Some(false);
|
||||
}
|
||||
if !drag.moved {
|
||||
let option_source_ids: Vec<op_editor_core::NodeId> =
|
||||
self.editor_state.selection.set.to_vec();
|
||||
if self.alt_held
|
||||
&& !option_source_ids.is_empty()
|
||||
&& self
|
||||
.editor_state
|
||||
.duplicate_selected(&mut self.next_node_id, 0.0)
|
||||
.is_some()
|
||||
{
|
||||
self.option_drag_source_ids = option_source_ids;
|
||||
self.mark_dirty();
|
||||
}
|
||||
if let Some(d) = self.node_drag.as_mut() {
|
||||
d.moved = true;
|
||||
}
|
||||
|
|
@ -263,6 +275,9 @@ impl WidgetHostNative {
|
|||
drag.last_screen_y = prev_screen_y;
|
||||
}
|
||||
}
|
||||
if let Some(drag) = self.node_drag {
|
||||
self.update_node_drag_preview(&drag);
|
||||
}
|
||||
return Some(true);
|
||||
}
|
||||
Some(false)
|
||||
|
|
@ -1380,7 +1395,9 @@ impl WidgetHostNative {
|
|||
// Drag ended — drop the transient smart-guide lines, then
|
||||
// run the drop policy (auto-layout reorder / reparent).
|
||||
self.editor_state.editor_ui.active_guides.clear();
|
||||
self.editor_state.editor_ui.canvas_drop_indicator = None;
|
||||
let _ = self.commit_node_drag(&drag);
|
||||
self.option_drag_source_ids.clear();
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1491,6 +1508,7 @@ impl WidgetHostNative {
|
|||
// run the drop policy (auto-layout reorder / reparent).
|
||||
self.editor_state.editor_ui.active_guides.clear();
|
||||
let _ = self.commit_node_drag(&drag);
|
||||
self.option_drag_source_ids.clear();
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1102,6 +1102,21 @@ impl WidgetHostNative {
|
|||
moved
|
||||
}
|
||||
|
||||
/// Left / Right arrow on the focused chat input. Consumes the key
|
||||
/// even at text boundaries so it never falls through to canvas nudge.
|
||||
pub fn apply_chat_input_caret(&mut self, forward: bool) -> bool {
|
||||
if !self.editor_state.chat.focused {
|
||||
return false;
|
||||
}
|
||||
if forward {
|
||||
self.editor_state.chat.input.move_right(false, self.now_ms);
|
||||
} else {
|
||||
self.editor_state.chat.input.move_left(false, self.now_ms);
|
||||
}
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
|
||||
/// Left / Right arrow on a focused property input — moves the
|
||||
/// text caret one character. Returns `false` when no property
|
||||
/// input is focused, so the caller falls back to node-nudge.
|
||||
|
|
@ -1215,6 +1230,14 @@ impl WidgetHostNative {
|
|||
return false;
|
||||
}
|
||||
let snap = self.editor_state.snapshot_for_history();
|
||||
if self
|
||||
.editor_state
|
||||
.move_selected_in_layout_direction(dx as f64, dy as f64)
|
||||
{
|
||||
self.editor_state.history_push_past(snap);
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.translate_selected(dx as f64, dy as f64) {
|
||||
self.editor_state.history_push_past(snap);
|
||||
self.mark_dirty();
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ use super::{
|
|||
};
|
||||
use op_editor_core::codegen::CodeSelection;
|
||||
use op_editor_ui::widgets::{
|
||||
rotation_corner_at_point, selection_handle_at_point, AIChatHit, AIChatPlaceholder, LayoutCx,
|
||||
LocalePicker, PropertyPanel, Toolbar, TopBar, TopBarHit, Widget, TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
|
||||
rotation_corner_at_point, selection_handle_at_point, AIChatHit, AIChatPlaceholder,
|
||||
CanvasViewport, LayoutCx, LocalePicker, PropertyPanel, Toolbar, TopBar, TopBarHit, Widget,
|
||||
TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
|
||||
};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
|
|
@ -1079,6 +1080,18 @@ impl WidgetHostNative {
|
|||
return true;
|
||||
}
|
||||
}
|
||||
let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene);
|
||||
if let Some(node_id) = canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y))
|
||||
{
|
||||
let ec_id = op_editor_core::NodeId::new(&node_id);
|
||||
return self.apply_canvas_node_press(
|
||||
ec_id,
|
||||
x,
|
||||
y,
|
||||
text_edit_was_active,
|
||||
viewport_height,
|
||||
);
|
||||
}
|
||||
if let Some(node_id) = self
|
||||
.layout_scene
|
||||
.node_at_doc_point(doc_point, self.editor_state.viewport.zoom)
|
||||
|
|
@ -1086,7 +1099,13 @@ impl WidgetHostNative {
|
|||
// Selection promotion / enter-group / drag start —
|
||||
// see `canvas_select_drag.rs`.
|
||||
let ec_id = op_editor_core::NodeId::new(&node_id);
|
||||
return self.apply_canvas_node_press(ec_id, x, y, text_edit_was_active);
|
||||
return self.apply_canvas_node_press(
|
||||
ec_id,
|
||||
x,
|
||||
y,
|
||||
text_edit_was_active,
|
||||
viewport_height,
|
||||
);
|
||||
}
|
||||
// Empty canvas press — start a marquee.
|
||||
let cleared_now = if !self.shift_held {
|
||||
|
|
|
|||
|
|
@ -359,6 +359,40 @@ impl WidgetHostNative {
|
|||
true
|
||||
}
|
||||
|
||||
pub(in crate::widget_host) fn scroll_layer_panel_selection_into_view(
|
||||
&mut self,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
use op_editor_ui::widgets::{LayerPanel, TOP_BAR_HEIGHT};
|
||||
use op_editor_ui::Rect;
|
||||
|
||||
if !self.editor_state.editor_ui.sidebar_open {
|
||||
return false;
|
||||
}
|
||||
let selected = self.editor_state.selection.anchor.clone();
|
||||
if !selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
let rect = Rect {
|
||||
origin: Point2D::new(0.0, TOP_BAR_HEIGHT),
|
||||
size: Point2D::new(
|
||||
self.editor_state.editor_ui.layer_panel_width,
|
||||
(viewport_height - TOP_BAR_HEIGHT).max(0.0),
|
||||
),
|
||||
};
|
||||
let panel = LayerPanel::from_editor(&self.editor_state);
|
||||
let Some(next) = panel.layers_offset_revealing(rect, &selected) else {
|
||||
return false;
|
||||
};
|
||||
let scroll = &mut self.editor_state.editor_ui.layer_layers_scroll;
|
||||
if (scroll.offset - next).abs() <= f32::EPSILON {
|
||||
return false;
|
||||
}
|
||||
scroll.offset = next;
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
|
||||
/// Wheel event — zoom centered at (x, y) over the canvas.
|
||||
/// Scroll the open icon picker's list when the pointer is over its panel.
|
||||
/// Shared by `apply_wheel` and `apply_pan_gesture` so trackpad pans scroll
|
||||
|
|
|
|||
|
|
@ -1005,6 +1005,7 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> {
|
|||
return;
|
||||
};
|
||||
b.host.set_modifier_shift(evt.shift_key());
|
||||
b.host.set_modifier_alt(evt.alt_key());
|
||||
b.host.set_clocks(now_ms_perf(), now_unix_secs());
|
||||
let (w, h) = b.backend.logical_size();
|
||||
let (x, y) =
|
||||
|
|
@ -1064,6 +1065,8 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> {
|
|||
let Ok(mut b) = inner.try_borrow_mut() else {
|
||||
return;
|
||||
};
|
||||
b.host.set_modifier_shift(evt.shift_key());
|
||||
b.host.set_modifier_alt(evt.alt_key());
|
||||
b.host.set_clocks(now_ms_perf(), now_unix_secs());
|
||||
let (x, y) =
|
||||
b.event_offset_to_logical(evt.offset_x() as f32, evt.offset_y() as f32);
|
||||
|
|
@ -1086,6 +1089,8 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> {
|
|||
let Ok(mut b) = inner.try_borrow_mut() else {
|
||||
return;
|
||||
};
|
||||
b.host.set_modifier_shift(evt.shift_key());
|
||||
b.host.set_modifier_alt(evt.alt_key());
|
||||
b.host.set_clocks(now_ms_perf(), now_unix_secs());
|
||||
let (w, h) = b.backend.logical_size();
|
||||
let was_middle = evt.button() == 1;
|
||||
|
|
@ -1222,6 +1227,7 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> {
|
|||
"ArrowLeft" if !is_mod => {
|
||||
consumed = b.host.apply_settings_caret(false)
|
||||
|| b.host.apply_chat_model_picker_caret(false)
|
||||
|| b.host.apply_chat_input_caret(false)
|
||||
|| b.host.apply_rename_caret(false)
|
||||
|| b.host.apply_text_edit_caret(false)
|
||||
|| b.host.apply_property_caret(false)
|
||||
|
|
@ -1230,6 +1236,7 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> {
|
|||
"ArrowRight" if !is_mod => {
|
||||
consumed = b.host.apply_settings_caret(true)
|
||||
|| b.host.apply_chat_model_picker_caret(true)
|
||||
|| b.host.apply_chat_input_caret(true)
|
||||
|| b.host.apply_rename_caret(true)
|
||||
|| b.host.apply_text_edit_caret(true)
|
||||
|| b.host.apply_property_caret(true)
|
||||
|
|
|
|||
|
|
@ -244,6 +244,10 @@ pub struct WidgetHost {
|
|||
/// selection live on cursor move, then end the transient state on
|
||||
/// release.
|
||||
pub(in crate::widget_host) node_drag: Option<node_drag::NodeDragState>,
|
||||
/// Original selected ids for an active Option-drag clone move.
|
||||
/// Drop hit-testing skips these so a fresh clone does not
|
||||
/// immediately reparent back into the source it overlaps.
|
||||
pub(in crate::widget_host) option_drag_source_ids: Vec<op_editor_core::NodeId>,
|
||||
/// Counter for minting fresh `NodeId`s when the user duplicates
|
||||
/// a node. Bumped past the highest sample id so new + sample
|
||||
/// nodes never collide on the same key. Matches the native
|
||||
|
|
@ -254,6 +258,9 @@ pub struct WidgetHost {
|
|||
/// can branch on shift+click for multi-select. Matches the
|
||||
/// native host's `shift_held` flag.
|
||||
pub(in crate::widget_host) shift_held: bool,
|
||||
/// Whether Alt/Option is currently held. Node dragging uses this
|
||||
/// to duplicate the current selection before moving it.
|
||||
pub(in crate::widget_host) alt_held: bool,
|
||||
/// Host clock in ms — set by `lib.rs` on each event from
|
||||
/// `performance.now()`. Used for double-click detection.
|
||||
pub(in crate::widget_host) now_ms: u64,
|
||||
|
|
@ -472,8 +479,10 @@ impl WidgetHost {
|
|||
variables_resize: None,
|
||||
handle_drag: None,
|
||||
node_drag: None,
|
||||
option_drag_source_ids: Vec::new(),
|
||||
next_node_id: 100,
|
||||
shift_held: false,
|
||||
alt_held: false,
|
||||
now_ms: 0,
|
||||
wall_now_secs: 0,
|
||||
last_viewport_w: 0.0,
|
||||
|
|
@ -489,6 +498,10 @@ impl WidgetHost {
|
|||
self.shift_held = held;
|
||||
}
|
||||
|
||||
pub fn set_modifier_alt(&mut self, held: bool) {
|
||||
self.alt_held = held;
|
||||
}
|
||||
|
||||
pub fn set_space_pan(&mut self, held: bool) {
|
||||
self.space_pan = held;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,25 @@ fn chat_model_picker_arrows_move_caret_for_insert_and_backspace() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_input_arrows_move_caret_for_insert_and_backspace() {
|
||||
let mut host = WidgetHost::new();
|
||||
host.editor_state.chat.focused = true;
|
||||
host.editor_state.chat.set_input_text("abcd");
|
||||
|
||||
assert!(host.apply_chat_input_caret(false));
|
||||
assert!(host.apply_chat_input_caret(false));
|
||||
assert_eq!(host.editor_state.chat.input_caret(), 2);
|
||||
|
||||
assert!(host.apply_text('X'));
|
||||
assert_eq!(host.editor_state.chat.input.text(), "abXcd");
|
||||
assert_eq!(host.editor_state.chat.input_caret(), 3);
|
||||
|
||||
assert!(host.apply_backspace());
|
||||
assert_eq!(host.editor_state.chat.input.text(), "abcd");
|
||||
assert_eq!(host.editor_state.chat.input_caret(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_model_picker_clear_button_empties_search() {
|
||||
let mut host = WidgetHost::new();
|
||||
|
|
|
|||
|
|
@ -147,6 +147,14 @@ impl WidgetHost {
|
|||
return false;
|
||||
}
|
||||
let snap = self.editor_state.snapshot_for_history();
|
||||
if self
|
||||
.editor_state
|
||||
.move_selected_in_layout_direction(dx as f64, dy as f64)
|
||||
{
|
||||
self.editor_state.history_push_past(snap);
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.translate_selected(dx as f64, dy as f64) {
|
||||
self.editor_state.history_push_past(snap);
|
||||
self.mark_dirty();
|
||||
|
|
@ -333,6 +341,21 @@ impl WidgetHost {
|
|||
true
|
||||
}
|
||||
|
||||
/// Left / Right arrow on the focused chat input. Consumes the key
|
||||
/// even at text boundaries so it never falls through to canvas nudge.
|
||||
pub fn apply_chat_input_caret(&mut self, forward: bool) -> bool {
|
||||
if !self.editor_state.chat.focused {
|
||||
return false;
|
||||
}
|
||||
if forward {
|
||||
self.editor_state.chat.input.move_right(false, self.now_ms);
|
||||
} else {
|
||||
self.editor_state.chat.input.move_left(false, self.now_ms);
|
||||
}
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+C — copy the selection into the clipboard.
|
||||
pub fn apply_copy(&mut self) -> bool {
|
||||
if self.editor_state.chat.focused {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
use super::WidgetHost;
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use op_editor_core::drag_mutators::{
|
||||
auto_layout_direction, parent_of, should_auto_reparent_outside_parent, FlexDirection,
|
||||
auto_layout_direction, parent_of, DragDropTarget, FlexDirection,
|
||||
};
|
||||
use op_editor_core::editor_ui_state::{CanvasDropIndicator, CanvasOverlayLine, CanvasOverlayRect};
|
||||
use op_editor_core::{NodeId, PenNodeExt};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
const NODE_DRAG_THRESHOLD_PX: f32 = 2.0;
|
||||
|
||||
struct DragCommitPlan {
|
||||
target: Option<DragDropTarget>,
|
||||
dropped_bounds: Rect,
|
||||
indicator: Option<CanvasDropIndicator>,
|
||||
}
|
||||
|
||||
struct ContainerDropCandidate {
|
||||
parent_id: NodeId,
|
||||
bounds: Rect,
|
||||
flex: Option<FlexDirection>,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
reparent_to_root: bool,
|
||||
sibling_mids: Vec<f32>,
|
||||
index: usize,
|
||||
insertion: Option<CanvasOverlayLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
|
@ -28,6 +37,7 @@ pub(in crate::widget_host) struct NodeDragState {
|
|||
impl WidgetHost {
|
||||
pub(in crate::widget_host) fn start_node_drag(&mut self, x: f32, y: f32) {
|
||||
self.editor_state.commit_history();
|
||||
self.option_drag_source_ids.clear();
|
||||
self.node_drag = Some(NodeDragState {
|
||||
last_screen_x: x,
|
||||
last_screen_y: y,
|
||||
|
|
@ -52,6 +62,17 @@ impl WidgetHost {
|
|||
return Some(false);
|
||||
}
|
||||
if !drag.moved {
|
||||
let option_source_ids: Vec<NodeId> = self.editor_state.selection.set.to_vec();
|
||||
if self.alt_held
|
||||
&& !option_source_ids.is_empty()
|
||||
&& self
|
||||
.editor_state
|
||||
.duplicate_selected(&mut self.next_node_id, 0.0)
|
||||
.is_some()
|
||||
{
|
||||
self.option_drag_source_ids = option_source_ids;
|
||||
self.mark_dirty();
|
||||
}
|
||||
if let Some(d) = self.node_drag.as_mut() {
|
||||
d.moved = true;
|
||||
}
|
||||
|
|
@ -112,6 +133,9 @@ impl WidgetHost {
|
|||
} else {
|
||||
self.editor_state.editor_ui.active_guides.clear();
|
||||
}
|
||||
if let Some(drag) = self.node_drag {
|
||||
self.update_node_drag_preview(&drag);
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +144,9 @@ impl WidgetHost {
|
|||
return false;
|
||||
};
|
||||
self.editor_state.editor_ui.active_guides.clear();
|
||||
self.editor_state.editor_ui.canvas_drop_indicator = None;
|
||||
let _ = self.commit_node_drag(&drag);
|
||||
self.option_drag_source_ids.clear();
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
|
|
@ -144,78 +170,276 @@ impl WidgetHost {
|
|||
mutated
|
||||
}
|
||||
|
||||
fn update_node_drag_preview(&mut self, drag: &NodeDragState) {
|
||||
let id = self.editor_state.selection.anchor.clone();
|
||||
let next = if id.is_real() {
|
||||
self.plan_drag_commit(&id, drag)
|
||||
.and_then(|plan| plan.indicator)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.editor_state.editor_ui.canvas_drop_indicator != next {
|
||||
self.editor_state.editor_ui.canvas_drop_indicator = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_drag_commit(&self, id: &NodeId, drag: &NodeDragState) -> Option<DragCommitPlan> {
|
||||
let children = self.editor_state.active_children();
|
||||
let parent_id = parent_of(children, id)?;
|
||||
let parent = op_editor_core::walkers::find_node(children, &parent_id)?;
|
||||
let flex = auto_layout_direction(parent);
|
||||
let current_parent = parent_of(children, id);
|
||||
let current_parent_flex = current_parent
|
||||
.as_ref()
|
||||
.and_then(|parent_id| op_editor_core::walkers::find_node(children, parent_id))
|
||||
.and_then(auto_layout_direction);
|
||||
let page = self.layout_scene.active_page()?;
|
||||
let node_scene = page.find(id.as_str())?;
|
||||
let mut nb = node_scene.aggregate_bounds();
|
||||
if flex.is_some() {
|
||||
if current_parent_flex.is_some() {
|
||||
nb.origin.x += drag.total_dx as f32;
|
||||
nb.origin.y += drag.total_dy as f32;
|
||||
}
|
||||
let pb = page.find(parent_id.as_str())?.aggregate_bounds();
|
||||
let outside = nb.origin.x + nb.size.x <= pb.origin.x
|
||||
|| nb.origin.x >= pb.origin.x + pb.size.x
|
||||
|| nb.origin.y + nb.size.y <= pb.origin.y
|
||||
|| nb.origin.y >= pb.origin.y + pb.size.y;
|
||||
let node_ref = op_editor_core::walkers::find_node(children, id)?;
|
||||
let reparent_to_root = outside && should_auto_reparent_outside_parent(node_ref);
|
||||
let vertical = matches!(flex, Some(FlexDirection::Vertical));
|
||||
let sibling_mids = parent
|
||||
.children()
|
||||
.map(|siblings| {
|
||||
siblings
|
||||
.iter()
|
||||
.filter(|sib| sib.id_str() != id.as_str())
|
||||
.map(|sib| {
|
||||
page.find(sib.id_str())
|
||||
.map(|sn| {
|
||||
let b = sn.aggregate_bounds();
|
||||
if vertical {
|
||||
b.origin.y + b.size.y / 2.0
|
||||
} else {
|
||||
b.origin.x + b.size.x / 2.0
|
||||
}
|
||||
})
|
||||
.unwrap_or(0.0)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let center = Point2D::new(nb.origin.x + nb.size.x / 2.0, nb.origin.y + nb.size.y / 2.0);
|
||||
let candidate = self.container_drop_candidate(id, center, nb);
|
||||
let mut indicator = None;
|
||||
let target = if let Some(candidate) = candidate {
|
||||
let same_parent = current_parent.as_ref() == Some(&candidate.parent_id);
|
||||
if same_parent && candidate.flex.is_none() {
|
||||
None
|
||||
} else {
|
||||
indicator = Some(CanvasDropIndicator {
|
||||
ghost: overlay_rect(nb),
|
||||
target: Some(overlay_rect(candidate.bounds)),
|
||||
insertion: candidate.insertion,
|
||||
});
|
||||
Some(DragDropTarget::Container {
|
||||
parent_id: candidate.parent_id,
|
||||
parent_abs_x: candidate.bounds.origin.x as f64,
|
||||
parent_abs_y: candidate.bounds.origin.y as f64,
|
||||
index: candidate.index,
|
||||
})
|
||||
}
|
||||
} else if current_parent.is_some() {
|
||||
indicator = Some(CanvasDropIndicator {
|
||||
ghost: overlay_rect(nb),
|
||||
target: None,
|
||||
insertion: None,
|
||||
});
|
||||
Some(DragDropTarget::PageRoot { index: 0 })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(DragCommitPlan {
|
||||
parent_id,
|
||||
flex,
|
||||
bounds: (nb.origin.x, nb.origin.y, nb.size.x, nb.size.y),
|
||||
reparent_to_root,
|
||||
sibling_mids,
|
||||
target,
|
||||
dropped_bounds: nb,
|
||||
indicator,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_drag_commit(&mut self, id: &NodeId, plan: DragCommitPlan) -> bool {
|
||||
let (bx, by, bw, bh) = plan.bounds;
|
||||
if plan.reparent_to_root {
|
||||
return self
|
||||
.editor_state
|
||||
.reparent_to_page_root(id, bx as f64, by as f64);
|
||||
}
|
||||
let Some(dir) = plan.flex else {
|
||||
let Some(target) = plan.target else {
|
||||
return false;
|
||||
};
|
||||
let drag_mid = match dir {
|
||||
FlexDirection::Vertical => by + bh / 2.0,
|
||||
FlexDirection::Horizontal => bx + bw / 2.0,
|
||||
let bounds = plan.dropped_bounds;
|
||||
self.editor_state.move_node_to_drop_target(
|
||||
id,
|
||||
target,
|
||||
bounds.origin.x as f64,
|
||||
bounds.origin.y as f64,
|
||||
bounds.size.x as f64,
|
||||
bounds.size.y as f64,
|
||||
)
|
||||
}
|
||||
|
||||
fn container_drop_candidate(
|
||||
&self,
|
||||
dragged_id: &NodeId,
|
||||
point: Point2D,
|
||||
dragged_bounds: Rect,
|
||||
) -> Option<ContainerDropCandidate> {
|
||||
let children = self.editor_state.active_children();
|
||||
let source = op_editor_core::walkers::find_node(children, dragged_id)?;
|
||||
let page = self.layout_scene.active_page()?;
|
||||
deepest_container_at(children, source, point, page, &self.option_drag_source_ids).map(
|
||||
|(parent_id, bounds, flex)| {
|
||||
let (index, insertion) = if let Some(dir) = flex {
|
||||
flex_insert_preview(children, page, &parent_id, dragged_id, dragged_bounds, dir)
|
||||
} else {
|
||||
(0, None)
|
||||
};
|
||||
ContainerDropCandidate {
|
||||
parent_id,
|
||||
bounds,
|
||||
flex,
|
||||
index,
|
||||
insertion,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn deepest_container_at(
|
||||
nodes: &[PenNode],
|
||||
source: &PenNode,
|
||||
point: Point2D,
|
||||
page: &op_editor_ui::layout_scene::ScenePage,
|
||||
excluded_ids: &[NodeId],
|
||||
) -> Option<(NodeId, Rect, Option<FlexDirection>)> {
|
||||
let mut hit = None;
|
||||
for node in nodes {
|
||||
if node.children().is_none() {
|
||||
continue;
|
||||
}
|
||||
let node_id = NodeId::new(node.id_str());
|
||||
if excluded_ids.contains(&node_id) {
|
||||
continue;
|
||||
}
|
||||
if op_editor_core::walkers::descendant_contains(source, &node_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(scene) = page.find(node.id_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut new_index = plan.sibling_mids.len();
|
||||
for (i, sib_mid) in plan.sibling_mids.iter().enumerate() {
|
||||
if drag_mid < *sib_mid {
|
||||
new_index = i;
|
||||
let bounds = scene.bounds;
|
||||
if !rect_contains(bounds, point) {
|
||||
continue;
|
||||
}
|
||||
hit = Some((node_id, bounds, auto_layout_direction(node)));
|
||||
if let Some(children) = node.children() {
|
||||
if let Some(deeper) = deepest_container_at(children, source, point, page, excluded_ids)
|
||||
{
|
||||
hit = Some(deeper);
|
||||
}
|
||||
}
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
fn flex_insert_preview(
|
||||
nodes: &[PenNode],
|
||||
page: &op_editor_ui::layout_scene::ScenePage,
|
||||
parent_id: &NodeId,
|
||||
dragged_id: &NodeId,
|
||||
dragged_bounds: Rect,
|
||||
dir: FlexDirection,
|
||||
) -> (usize, Option<CanvasOverlayLine>) {
|
||||
let Some(parent) = op_editor_core::walkers::find_node(nodes, parent_id) else {
|
||||
return (0, None);
|
||||
};
|
||||
let Some(parent_scene) = page.find(parent_id.as_str()) else {
|
||||
return (0, None);
|
||||
};
|
||||
let parent_bounds = parent_scene.bounds;
|
||||
let vertical = matches!(dir, FlexDirection::Vertical);
|
||||
let drag_mid = if vertical {
|
||||
dragged_bounds.origin.y + dragged_bounds.size.y / 2.0
|
||||
} else {
|
||||
dragged_bounds.origin.x + dragged_bounds.size.x / 2.0
|
||||
};
|
||||
let mut index = parent
|
||||
.children()
|
||||
.map(|children| {
|
||||
children
|
||||
.iter()
|
||||
.filter(|node| node.id_str() != dragged_id.as_str())
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if let Some(children) = parent.children() {
|
||||
for (i, child) in children
|
||||
.iter()
|
||||
.filter(|node| node.id_str() != dragged_id.as_str())
|
||||
.enumerate()
|
||||
{
|
||||
let Some(scene) = page.find(child.id_str()) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = scene.aggregate_bounds();
|
||||
let mid = if vertical {
|
||||
bounds.origin.y + bounds.size.y / 2.0
|
||||
} else {
|
||||
bounds.origin.x + bounds.size.x / 2.0
|
||||
};
|
||||
if drag_mid < mid {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.editor_state
|
||||
.reorder_child_to_index(&plan.parent_id, id, new_index)
|
||||
}
|
||||
let insertion = flex_insertion_line(parent, page, parent_bounds, dragged_id, index, vertical);
|
||||
(index, insertion)
|
||||
}
|
||||
|
||||
fn flex_insertion_line(
|
||||
parent: &PenNode,
|
||||
page: &op_editor_ui::layout_scene::ScenePage,
|
||||
parent_bounds: Rect,
|
||||
dragged_id: &NodeId,
|
||||
index: usize,
|
||||
vertical: bool,
|
||||
) -> Option<CanvasOverlayLine> {
|
||||
let siblings: Vec<Rect> = parent
|
||||
.children()?
|
||||
.iter()
|
||||
.filter(|node| node.id_str() != dragged_id.as_str())
|
||||
.filter_map(|node| {
|
||||
page.find(node.id_str())
|
||||
.map(|scene| scene.aggregate_bounds())
|
||||
})
|
||||
.collect();
|
||||
let inset = 8.0_f32.min(parent_bounds.size.x.max(parent_bounds.size.y) / 4.0);
|
||||
if vertical {
|
||||
let y = if siblings.is_empty() {
|
||||
parent_bounds.origin.y + parent_bounds.size.y / 2.0
|
||||
} else if index == 0 {
|
||||
siblings[0].origin.y
|
||||
} else if index >= siblings.len() {
|
||||
let last = siblings[siblings.len() - 1];
|
||||
last.origin.y + last.size.y
|
||||
} else {
|
||||
let prev = siblings[index - 1];
|
||||
let next = siblings[index];
|
||||
(prev.origin.y + prev.size.y + next.origin.y) / 2.0
|
||||
};
|
||||
Some(CanvasOverlayLine::new(
|
||||
(parent_bounds.origin.x + inset) as f64,
|
||||
y as f64,
|
||||
(parent_bounds.origin.x + parent_bounds.size.x - inset) as f64,
|
||||
y as f64,
|
||||
))
|
||||
} else {
|
||||
let x = if siblings.is_empty() {
|
||||
parent_bounds.origin.x + parent_bounds.size.x / 2.0
|
||||
} else if index == 0 {
|
||||
siblings[0].origin.x
|
||||
} else if index >= siblings.len() {
|
||||
let last = siblings[siblings.len() - 1];
|
||||
last.origin.x + last.size.x
|
||||
} else {
|
||||
let prev = siblings[index - 1];
|
||||
let next = siblings[index];
|
||||
(prev.origin.x + prev.size.x + next.origin.x) / 2.0
|
||||
};
|
||||
Some(CanvasOverlayLine::new(
|
||||
x as f64,
|
||||
(parent_bounds.origin.y + inset) as f64,
|
||||
x as f64,
|
||||
(parent_bounds.origin.y + parent_bounds.size.y - inset) as f64,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_contains(rect: Rect, point: Point2D) -> bool {
|
||||
point.x >= rect.origin.x
|
||||
&& point.x <= rect.origin.x + rect.size.x
|
||||
&& point.y >= rect.origin.y
|
||||
&& point.y <= rect.origin.y + rect.size.y
|
||||
}
|
||||
|
||||
fn overlay_rect(rect: Rect) -> CanvasOverlayRect {
|
||||
CanvasOverlayRect::new(
|
||||
rect.origin.x as f64,
|
||||
rect.origin.y as f64,
|
||||
rect.size.x as f64,
|
||||
rect.size.y as f64,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,19 @@ const VW: f32 = 1200.0;
|
|||
const VH: f32 = 800.0;
|
||||
|
||||
fn seed(host: &mut WidgetHost) {
|
||||
let doc = jian_ops_schema::load_str(
|
||||
seed_json(
|
||||
host,
|
||||
r##"{"version":"0.8.0","children":[
|
||||
{"type":"rectangle","id":"box","name":"Box","x":100,"y":100,"width":120,"height":80,
|
||||
"fill":[{"type":"solid","color":"#2563EB"}]}
|
||||
]}"##,
|
||||
)
|
||||
.expect("fixture JSON parses")
|
||||
.value;
|
||||
);
|
||||
}
|
||||
|
||||
fn seed_json(host: &mut WidgetHost, json: &str) {
|
||||
let doc = jian_ops_schema::load_str(json)
|
||||
.expect("fixture JSON parses")
|
||||
.value;
|
||||
host.editor_state = op_editor_core::EditorState::from_document(doc);
|
||||
host.editor_state.tool = Tool::Select;
|
||||
host.editor_state_dirty = true;
|
||||
|
|
@ -69,6 +74,60 @@ fn select_tool_dragging_selected_node_moves_it_without_resizing() {
|
|||
assert_eq!(bounds.h, 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_dragging_selected_node_duplicates_and_moves_the_clone() {
|
||||
let mut host = WidgetHost::new();
|
||||
seed(&mut host);
|
||||
|
||||
host.set_modifier_alt(true);
|
||||
let press = screen(&host, 160.0, 140.0);
|
||||
let move_to = screen(&host, 200.0, 165.0);
|
||||
|
||||
assert!(host.apply_press(press.x, press.y, VW, VH));
|
||||
assert_eq!(host.editor_state.selection.anchor, NodeId::new("box"));
|
||||
assert!(host.apply_cursor_move(move_to.x, move_to.y));
|
||||
assert_eq!(host.editor_state.active_children().len(), 2);
|
||||
assert!(host.apply_release_with_viewport(VW, VH));
|
||||
|
||||
let children = host.editor_state.active_children();
|
||||
assert_eq!(children.len(), 2);
|
||||
let original = find_node(children, &NodeId::new("box")).expect("original remains");
|
||||
assert_eq!(own_bounds(original).x, 100.0);
|
||||
assert_eq!(own_bounds(original).y, 100.0);
|
||||
|
||||
let clone_id = host.editor_state.selection.anchor.clone();
|
||||
assert_ne!(clone_id, NodeId::new("box"));
|
||||
let clone = find_node(children, &clone_id).expect("clone selected");
|
||||
let clone_bounds = own_bounds(clone);
|
||||
assert_eq!(clone_bounds.x, 140.0);
|
||||
assert_eq!(clone_bounds.y, 125.0);
|
||||
assert_eq!(clone_bounds.w, 120.0);
|
||||
assert_eq!(clone_bounds.h, 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_nudge_reorders_selected_child_on_layout_axis() {
|
||||
let mut host = WidgetHost::new();
|
||||
seed_json(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[{
|
||||
"type":"frame","id":"stack","name":"Stack","x":400,"y":60,"width":200,"height":300,
|
||||
"layout":"vertical","gap":8,
|
||||
"children":[
|
||||
{"type":"rectangle","id":"a","name":"A","width":80,"height":40},
|
||||
{"type":"rectangle","id":"b","name":"B","width":80,"height":40},
|
||||
{"type":"rectangle","id":"c","name":"C","width":80,"height":40}
|
||||
]}
|
||||
]}"#,
|
||||
);
|
||||
host.editor_state.set_single_selection(NodeId::new("b"));
|
||||
|
||||
assert!(host.apply_nudge(0.0, 1.0));
|
||||
|
||||
assert_eq!(child_order(&host, "stack"), vec!["a", "c", "b"]);
|
||||
assert_eq!(host.editor_state.selection.anchor, NodeId::new("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_tool_dragging_flex_child_reorders_on_release_like_native() {
|
||||
let mut host = WidgetHost::new();
|
||||
|
|
@ -96,6 +155,14 @@ fn select_tool_dragging_flex_child_reorders_on_release_like_native() {
|
|||
|
||||
let move_to = screen(&host, 440.0, 160.0);
|
||||
assert!(host.apply_cursor_move(move_to.x, move_to.y));
|
||||
let preview = host
|
||||
.editor_state
|
||||
.editor_ui
|
||||
.canvas_drop_indicator
|
||||
.as_ref()
|
||||
.expect("dragging a flex child paints a drop preview");
|
||||
assert!(preview.target.is_some());
|
||||
assert!(preview.insertion.is_some());
|
||||
|
||||
let a = find_node(host.editor_state.active_children(), &NodeId::new("a")).expect("a present");
|
||||
assert_eq!(
|
||||
|
|
@ -105,10 +172,160 @@ fn select_tool_dragging_flex_child_reorders_on_release_like_native() {
|
|||
);
|
||||
|
||||
assert!(host.apply_release_with_viewport(VW, VH));
|
||||
assert!(host.editor_state.editor_ui.canvas_drop_indicator.is_none());
|
||||
|
||||
assert_eq!(child_order(&host, "stack"), vec!["b", "a", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_tool_dragging_flex_child_to_blank_canvas_makes_root_at_dropped_position() {
|
||||
let mut host = WidgetHost::new();
|
||||
seed_json(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[{
|
||||
"type":"frame","id":"stack","name":"Stack","x":400,"y":60,"width":200,"height":300,
|
||||
"layout":"vertical","gap":8,
|
||||
"children":[
|
||||
{"type":"rectangle","id":"a","name":"A","width":80,"height":40},
|
||||
{"type":"rectangle","id":"b","name":"B","width":80,"height":40},
|
||||
{"type":"rectangle","id":"c","name":"C","width":80,"height":40}
|
||||
]}
|
||||
]}"#,
|
||||
);
|
||||
host.editor_state.editor_ui.entered_container = Some(NodeId::new("stack"));
|
||||
|
||||
let press = screen(&host, 440.0, 80.0);
|
||||
assert!(host.apply_press(press.x, press.y, VW, VH));
|
||||
assert_eq!(host.editor_state.selection.anchor, NodeId::new("a"));
|
||||
let move_to = screen(&host, 760.0, 80.0);
|
||||
assert!(host.apply_cursor_move(move_to.x, move_to.y));
|
||||
let preview = host
|
||||
.editor_state
|
||||
.editor_ui
|
||||
.canvas_drop_indicator
|
||||
.as_ref()
|
||||
.expect("drag out paints a root-drop ghost");
|
||||
assert!(preview.target.is_none());
|
||||
assert!(preview.insertion.is_none());
|
||||
assert!((preview.ghost.x - 720.0).abs() < 1.0);
|
||||
assert!((preview.ghost.y - 60.0).abs() < 1.0);
|
||||
assert!(host.apply_release_with_viewport(VW, VH));
|
||||
assert!(host.editor_state.editor_ui.canvas_drop_indicator.is_none());
|
||||
|
||||
let children = host.editor_state.active_children();
|
||||
assert_eq!(children[0].id_str(), "a");
|
||||
let moved = find_node(children, &NodeId::new("a")).unwrap();
|
||||
assert!((moved.base().x.unwrap_or(0.0) - 720.0).abs() < 1.0);
|
||||
assert!((moved.base().y.unwrap_or(0.0) - 60.0).abs() < 1.0);
|
||||
assert_eq!(child_order(&host, "stack"), vec!["b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_tool_dragging_fill_sized_child_to_blank_canvas_freezes_resolved_size() {
|
||||
let mut host = WidgetHost::new();
|
||||
seed_json(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[{
|
||||
"type":"frame","id":"screen","name":"Screen","x":400,"y":60,"width":360,"height":220,
|
||||
"layout":"vertical","gap":8,
|
||||
"children":[
|
||||
{"type":"rectangle","id":"box","name":"Box","width":"fill_container","height":72}
|
||||
]}
|
||||
]}"#,
|
||||
);
|
||||
host.editor_state.editor_ui.entered_container = Some(NodeId::new("screen"));
|
||||
|
||||
let press = screen(&host, 420.0, 80.0);
|
||||
assert!(host.apply_press(press.x, press.y, VW, VH));
|
||||
assert_eq!(host.editor_state.selection.anchor, NodeId::new("box"));
|
||||
let move_to = screen(&host, 840.0, 80.0);
|
||||
assert!(host.apply_cursor_move(move_to.x, move_to.y));
|
||||
let preview = host
|
||||
.editor_state
|
||||
.editor_ui
|
||||
.canvas_drop_indicator
|
||||
.as_ref()
|
||||
.expect("drag out paints a root-drop ghost");
|
||||
let expected_w = preview.ghost.w;
|
||||
let expected_h = preview.ghost.h;
|
||||
assert!(preview.target.is_none());
|
||||
|
||||
assert!(host.apply_release_with_viewport(VW, VH));
|
||||
|
||||
let moved = find_node(host.editor_state.active_children(), &NodeId::new("box")).unwrap();
|
||||
assert!(
|
||||
(moved.width_px().unwrap_or(0.0) - expected_w).abs() < 1.0,
|
||||
"root width should freeze to dragged width {expected_w}, got {:?}",
|
||||
moved.width_px()
|
||||
);
|
||||
assert!(
|
||||
(moved.height_px().unwrap_or(0.0) - expected_h).abs() < 1.0,
|
||||
"root height should freeze to dragged height {expected_h}, got {:?}",
|
||||
moved.height_px()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_tool_dragging_child_into_sibling_frame_reparents_like_native() {
|
||||
let mut host = WidgetHost::new();
|
||||
seed_json(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[
|
||||
{"type":"frame","id":"src","name":"Source","x":400,"y":60,"width":200,"height":120,
|
||||
"children":[
|
||||
{"type":"rectangle","id":"box","name":"Box","x":20,"y":20,"width":50,"height":50}
|
||||
]},
|
||||
{"type":"frame","id":"target","name":"Target","x":700,"y":60,"width":220,"height":160,
|
||||
"children":[]}
|
||||
]}"#,
|
||||
);
|
||||
host.editor_state.editor_ui.entered_container = Some(NodeId::new("src"));
|
||||
|
||||
let press = screen(&host, 445.0, 105.0);
|
||||
assert!(host.apply_press(press.x, press.y, VW, VH));
|
||||
assert_eq!(host.editor_state.selection.anchor, NodeId::new("box"));
|
||||
let move_to = screen(&host, 760.0, 100.0);
|
||||
assert!(host.apply_cursor_move(move_to.x, move_to.y));
|
||||
assert!(host.apply_release_with_viewport(VW, VH));
|
||||
|
||||
let src = find_node(host.editor_state.active_children(), &NodeId::new("src")).unwrap();
|
||||
assert!(src.children().unwrap().is_empty());
|
||||
let target = find_node(host.editor_state.active_children(), &NodeId::new("target")).unwrap();
|
||||
let moved = &target.children().unwrap()[0];
|
||||
assert_eq!(moved.id_str(), "box");
|
||||
assert!((moved.base().x.unwrap_or(0.0) - 35.0).abs() < 1.0);
|
||||
assert!((moved.base().y.unwrap_or(0.0) - 15.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_tool_dragging_child_to_blank_canvas_makes_it_page_root_like_native() {
|
||||
let mut host = WidgetHost::new();
|
||||
seed_json(
|
||||
&mut host,
|
||||
r#"{"version":"0.8.0","children":[
|
||||
{"type":"frame","id":"card","name":"Card","x":400,"y":60,"width":200,"height":100,
|
||||
"children":[
|
||||
{"type":"rectangle","id":"box","name":"Box","x":20,"y":20,"width":50,"height":50}
|
||||
]}
|
||||
]}"#,
|
||||
);
|
||||
host.editor_state.editor_ui.entered_container = Some(NodeId::new("card"));
|
||||
|
||||
let press = screen(&host, 440.0, 100.0);
|
||||
assert!(host.apply_press(press.x, press.y, VW, VH));
|
||||
let move_to = screen(&host, 840.0, 100.0);
|
||||
assert!(host.apply_cursor_move(move_to.x, move_to.y));
|
||||
assert!(host.apply_release_with_viewport(VW, VH));
|
||||
|
||||
let children = host.editor_state.active_children();
|
||||
assert_eq!(children[0].id_str(), "box");
|
||||
let card = find_node(children, &NodeId::new("card")).unwrap();
|
||||
assert!(card.children().unwrap().is_empty());
|
||||
let moved = find_node(children, &NodeId::new("box")).unwrap();
|
||||
assert!((moved.base().x.unwrap_or(0.0) - 820.0).abs() < 1.0);
|
||||
assert!((moved.base().y.unwrap_or(0.0) - 80.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_selection_with_a_locked_node_does_not_drift_it_in_the_scene() {
|
||||
let mut host = WidgetHost::new();
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
//! dispatchers in their own sibling modules (mirroring the native
|
||||
//! host's layout) so this file stays under the 800-line cap.
|
||||
use op_editor_ui::widgets::{
|
||||
AIChatHit, AIChatPlaceholder, LayerPanel, LayerPanelHit, LocalePicker, PropertyPanel, Toolbar,
|
||||
TopBarHit, TOP_BAR_HEIGHT,
|
||||
AIChatHit, AIChatPlaceholder, CanvasViewport, LayerPanel, LayerPanelHit, LocalePicker,
|
||||
PropertyPanel, Toolbar, TopBarHit, TOP_BAR_HEIGHT,
|
||||
};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
|
|
@ -919,8 +919,34 @@ impl WidgetHost {
|
|||
// is under the cursor — `node_at_doc_point` queries
|
||||
// the layout-resolved render scene.
|
||||
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height);
|
||||
let canvas_rect = Rect {
|
||||
origin: Point2D::new(cx0, cy0),
|
||||
size: Point2D::new(_cw, _ch),
|
||||
};
|
||||
let canvas_local = Point2D::new(x - cx0, y - cy0);
|
||||
let doc_point = self.editor_state.viewport.to_document(canvas_local);
|
||||
let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene);
|
||||
if let Some(sc_node_id) =
|
||||
canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y))
|
||||
{
|
||||
let node_id = op_editor_core::NodeId::new(&sc_node_id);
|
||||
if self.shift_held {
|
||||
let was_in_set = self.editor_state.is_selected(&node_id);
|
||||
self.editor_state.toggle_selection(node_id);
|
||||
if !was_in_set {
|
||||
self.start_node_drag(x, y);
|
||||
}
|
||||
} else {
|
||||
let already_in_set = self.editor_state.is_selected(&node_id);
|
||||
if !already_in_set || self.editor_state.selection_count() == 1 {
|
||||
self.editor_state.set_single_selection(node_id);
|
||||
}
|
||||
self.start_node_drag(x, y);
|
||||
}
|
||||
self.scroll_layer_panel_selection_into_view(viewport_height);
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
let hit = self
|
||||
.layout_scene
|
||||
.node_at_doc_point(doc_point, self.editor_state.viewport.zoom);
|
||||
|
|
@ -954,6 +980,7 @@ impl WidgetHost {
|
|||
self.editor_state.set_single_selection(node_id);
|
||||
}
|
||||
}
|
||||
self.scroll_layer_panel_selection_into_view(viewport_height);
|
||||
if should_start_drag {
|
||||
self.start_node_drag(x, y);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,4 +280,29 @@ impl WidgetHost {
|
|||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(in crate::widget_host) fn scroll_layer_panel_selection_into_view(
|
||||
&mut self,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
if !self.editor_state.editor_ui.sidebar_open {
|
||||
return false;
|
||||
}
|
||||
let selected = self.editor_state.selection.anchor.clone();
|
||||
if !selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
let rect = self.layer_panel_rect(viewport_height);
|
||||
let panel = LayerPanel::from_editor(&self.editor_state);
|
||||
let Some(next) = panel.layers_offset_revealing(rect, &selected) else {
|
||||
return false;
|
||||
};
|
||||
let scroll = &mut self.editor_state.editor_ui.layer_layers_scroll;
|
||||
if (scroll.offset - next).abs() <= f32::EPSILON {
|
||||
return false;
|
||||
}
|
||||
scroll.offset = next;
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue