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 fd919a700..c56ac29b4 100644 --- a/crates/op-host-web/src/widget_host/node_drag.rs +++ b/crates/op-host-web/src/widget_host/node_drag.rs @@ -73,7 +73,36 @@ impl WidgetHost { drag.last_screen_y = y; } if self.editor_state.translate_selected(dx as f64, dy as f64) { - self.mark_dirty(); + // Incremental scene patch instead of a full serde reconversion per + // moved pixel (mirrors the native host). A plain node drag only + // moves absolute-positioned nodes, so translate just those scene + // nodes in place and defer the full rebuild to release. Flex-flow + // children are positioned by their parent, not absolute coords, so + // they still need the full pass. Skip the fast path when a + // reconversion is already pending (`editor_state_dirty`). + if !self.editor_state_dirty { + let children = self.editor_state.active_children(); + let ids: Vec = self + .editor_state + .selection + .set + .iter() + .filter(|id| { + // Move exactly what `translate_selected` moved in the + // document: editable nodes only (locked / hidden are + // skipped there) and not flex-flow children (positioned + // by their parent). Otherwise the scene would drift nodes + // the doc never moved, then snap back on the release-time + // reconversion. + self.editor_state.is_editable(id) + && !op_editor_core::walkers::is_flow_child_of_flex(children, id) + }) + .map(|id| id.as_str().to_string()) + .collect(); + let _ = self.layout_scene.translate_nodes(&ids, dx, dy); + } else { + self.mark_dirty(); + } } else { self.editor_state.editor_ui.active_guides.clear(); } 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 17d6fe80e..a5ed41806 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 @@ -41,6 +41,15 @@ fn box_bounds(host: &WidgetHost) -> op_editor_core::DocRect { } } +fn scene_origin(host: &WidgetHost, id: &str) -> Point2D { + host.layout_scene + .active_page() + .and_then(|page| page.find(id)) + .expect("node present in scene") + .bounds + .origin +} + #[test] fn select_tool_dragging_selected_node_moves_it_without_resizing() { let mut host = WidgetHost::new(); @@ -99,3 +108,51 @@ fn select_tool_dragging_flex_child_reorders_on_release_like_native() { assert_eq!(child_order(&host, "stack"), vec!["b", "a", "c"]); } + +#[test] +fn dragging_a_selection_with_a_locked_node_does_not_drift_it_in_the_scene() { + let mut host = WidgetHost::new(); + let doc = jian_ops_schema::load_str( + r##"{"version":"0.8.0","children":[ + {"type":"rectangle","id":"free","name":"Free","x":100,"y":100,"width":80,"height":60}, + {"type":"rectangle","id":"locked","name":"Locked","x":300,"y":100,"width":80,"height":60,"locked":true} + ]}"##, + ) + .expect("fixture JSON parses") + .value; + host.editor_state = op_editor_core::EditorState::from_document(doc); + host.editor_state.tool = Tool::Select; + + // Press the free node to start a drag, then widen the selection to include + // the locked node — the incremental fast path reads the live selection at + // move time. + let press = screen(&host, 140.0, 130.0); + assert!(host.apply_press(press.x, press.y, VW, VH)); + host.editor_state.selection.set = vec![NodeId::new("free"), NodeId::new("locked")]; + + // Force-resolve the scene, then clear dirty so the move takes the + // incremental path (a real frame would have painted + cleared the flag). + host.editor_state_dirty = true; + host.refresh_layout_scene(); + host.editor_state_dirty = false; + + let free_before = scene_origin(&host, "free"); + let locked_before = scene_origin(&host, "locked"); + + let move_to = screen(&host, 200.0, 130.0); // +60 doc px in x + assert!(host.apply_cursor_move(move_to.x, move_to.y)); + + let free_after = scene_origin(&host, "free"); + let locked_after = scene_origin(&host, "locked"); + + // The editable node tracks the drag in the scene... + assert!( + (free_after.x - free_before.x).abs() > 1.0, + "editable node should move in the scene during the drag" + ); + // ...the locked node, which `translate_selected` leaves untouched, must not + // drift — otherwise it would jump and then snap back on the release-time + // reconversion. + assert_eq!(locked_after.x, locked_before.x, "locked node must not drift in x"); + assert_eq!(locked_after.y, locked_before.y, "locked node must not drift in y"); +}