feat(canvas): add cross-container drag controls

This commit is contained in:
Fini 2026-07-06 22:24:05 +08:00
parent fcf5ddf7a4
commit 8d1e2e5dd7
21 changed files with 1689 additions and 210 deletions

View file

@ -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,
)
}
}

View file

@ -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();

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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);

View file

@ -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

View file

@ -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,
)
}

View file

@ -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
);
}

View file

@ -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;
}

View file

@ -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();

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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;
}

View file

@ -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();

View file

@ -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 {

View file

@ -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,
)
}

View file

@ -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();

View file

@ -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);
}

View file

@ -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
}
}