diff --git a/crates/op-editor-core/src/drag_mutators.rs b/crates/op-editor-core/src/drag_mutators.rs index 511464942..fb2a47a51 100644 --- a/crates/op-editor-core/src/drag_mutators.rs +++ b/crates/op-editor-core/src/drag_mutators.rs @@ -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 { @@ -38,23 +55,12 @@ pub fn auto_layout_direction(node: &PenNode) -> Option { } } -/// 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, + ) + } } diff --git a/crates/op-editor-core/src/tests_drag_mutators.rs b/crates/op-editor-core/src/tests_drag_mutators.rs index e87e38082..14ea8700e 100644 --- a/crates/op-editor-core/src/tests_drag_mutators.rs +++ b/crates/op-editor-core/src/tests_drag_mutators.rs @@ -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(); diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index 17898fd3d..a31b02ca4 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -1242,25 +1242,26 @@ impl ApplicationHandler 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 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: diff --git a/crates/op-host-desktop/src/keyboard_input.rs b/crates/op-host-desktop/src/keyboard_input.rs index b957d33c6..c36b9f5ab 100644 --- a/crates/op-host-desktop/src/keyboard_input.rs +++ b/crates/op-host-desktop/src/keyboard_input.rs @@ -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) diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index b7d6e2581..18c9f746f 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -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 diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index 12355e24a..25acecc67 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -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); diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index eac6de3d1..0acede6a4 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -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, + /// 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, /// 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 diff --git a/crates/op-host-native/src/widget_host/canvas_select_drag.rs b/crates/op-host-native/src/widget_host/canvas_select_drag.rs index a3cb5ddf4..93fbd650f 100644 --- a/crates/op-host-native/src/widget_host/canvas_select_drag.rs +++ b/crates/op-host-native/src/widget_host/canvas_select_drag.rs @@ -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, + dropped_bounds: Rect, + indicator: Option, +} + +struct ContainerDropCandidate { parent_id: NodeId, - /// `Some(direction)` when the parent is an auto-layout container. + bounds: Rect, flex: Option, - /// 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, + index: usize, + insertion: Option, } 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 { 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 { + 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)> { + 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) { + 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 { + let siblings: Vec = 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, + ) +} diff --git a/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs b/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs index c1be1d8cc..fa97ac844 100644 --- a/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs +++ b/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs @@ -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::>() + .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 + ); +} diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 7e4276b42..f49c67b73 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -191,6 +191,18 @@ impl WidgetHostNative { return Some(false); } if !drag.moved { + let option_source_ids: Vec = + 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; } diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 229fd5d32..afedfcee2 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -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(); diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 7fef0f66e..d099dfcb7 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -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 { diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index 6284725db..65e28e380 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -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 diff --git a/crates/op-host-web/src/canvaskit.rs b/crates/op-host-web/src/canvaskit.rs index e020defc2..aeccfd16a 100644 --- a/crates/op-host-web/src/canvaskit.rs +++ b/crates/op-host-web/src/canvaskit.rs @@ -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) diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index e65086e2e..4a81dd4a1 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -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, + /// 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, /// 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; } diff --git a/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs b/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs index ca0a75902..f2fb55f6a 100644 --- a/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs +++ b/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs @@ -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(); diff --git a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs index d365e4503..078871d27 100644 --- a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs +++ b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs @@ -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 { diff --git a/crates/op-host-web/src/widget_host/node_drag.rs b/crates/op-host-web/src/widget_host/node_drag.rs index 80c4ec686..494cc254a 100644 --- a/crates/op-host-web/src/widget_host/node_drag.rs +++ b/crates/op-host-web/src/widget_host/node_drag.rs @@ -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, + dropped_bounds: Rect, + indicator: Option, +} + +struct ContainerDropCandidate { parent_id: NodeId, + bounds: Rect, flex: Option, - bounds: (f32, f32, f32, f32), - reparent_to_root: bool, - sibling_mids: Vec, + index: usize, + insertion: Option, } #[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 = 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 { 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 { + 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)> { + 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) { + 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 { + let siblings: Vec = 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, + ) +} diff --git a/crates/op-host-web/src/widget_host/node_drag_tests.rs b/crates/op-host-web/src/widget_host/node_drag_tests.rs index 165c043fa..957c3454f 100644 --- a/crates/op-host-web/src/widget_host/node_drag_tests.rs +++ b/crates/op-host-web/src/widget_host/node_drag_tests.rs @@ -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(); diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 02ae66f53..b28155165 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -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); } diff --git a/crates/op-host-web/src/widget_host/scroll.rs b/crates/op-host-web/src/widget_host/scroll.rs index 8d1c97c97..efe3b3526 100644 --- a/crates/op-host-web/src/widget_host/scroll.rs +++ b/crates/op-host-web/src/widget_host/scroll.rs @@ -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 + } }