perf(editor): patch the scene incrementally on web node drag

A web node drag called mark_dirty() per move, forcing a full serde doc->scene
reconversion + taffy solve on every pixel. Mirror the native host: translate
just the affected absolute scene nodes in place (editable + non-flex-flow,
matching translate_selected) and defer the full rebuild to release. Adds a
regression test that a locked node in the selection does not drift in the scene.
This commit is contained in:
Kayshen-X 2026-06-19 15:13:24 +08:00
parent 7b80dc51e7
commit 10daa64ffa
2 changed files with 87 additions and 1 deletions

View file

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

View file

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