From 7aede2734727a13b2314b7036de56f7659f0606b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Jul 2025 17:49:45 -0700 Subject: [PATCH 1/5] Add shaking input gesture to disconnect a node being dragged --- .../input_mapper/input_mapper_message.rs | 1 + .../messages/input_mapper/input_mappings.rs | 22 ++++--- .../input_mapper/utility_types/macros.rs | 4 +- .../input_mapper/utility_types/misc.rs | 3 + .../input_preprocessor_message.rs | 1 + .../input_preprocessor_message_handler.rs | 8 +++ .../document/node_graph/node_graph_message.rs | 1 + .../node_graph/node_graph_message_handler.rs | 45 +++++++++++++ frontend/src/io-managers/input.ts | 63 +++++++++++++++++++ frontend/wasm/src/editor_api.rs | 11 ++++ 10 files changed, 149 insertions(+), 10 deletions(-) diff --git a/editor/src/messages/input_mapper/input_mapper_message.rs b/editor/src/messages/input_mapper/input_mapper_message.rs index b09d09e309..bfcda26c73 100644 --- a/editor/src/messages/input_mapper/input_mapper_message.rs +++ b/editor/src/messages/input_mapper/input_mapper_message.rs @@ -19,5 +19,6 @@ pub enum InputMapperMessage { // Messages PointerMove, + PointerShake, WheelScroll, } diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 02dafca170..71fb27a01c 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -54,14 +54,15 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(KeyZ); modifiers=[Accel, MouseLeft], action_dispatch=DocumentMessage::Noop), // // NodeGraphMessage - entry!(KeyDown(MouseLeft); action_dispatch=NodeGraphMessage::PointerDown {shift_click: false, control_click: false, alt_click: false, right_click: false}), - entry!(KeyDown(MouseLeft); modifiers=[Shift], action_dispatch=NodeGraphMessage::PointerDown {shift_click: true, control_click: false, alt_click: false, right_click: false}), - entry!(KeyDown(MouseLeft); modifiers=[Accel], action_dispatch=NodeGraphMessage::PointerDown {shift_click: false, control_click: true, alt_click: false, right_click: false}), - entry!(KeyDown(MouseLeft); modifiers=[Shift, Accel], action_dispatch=NodeGraphMessage::PointerDown {shift_click: true, control_click: true, alt_click: false, right_click: false}), - entry!(KeyDown(MouseLeft); modifiers=[Alt], action_dispatch=NodeGraphMessage::PointerDown {shift_click: false, control_click: false, alt_click: true, right_click: false}), - entry!(KeyDown(MouseRight); action_dispatch=NodeGraphMessage::PointerDown {shift_click: false, control_click: false, alt_click: false, right_click: true}), + entry!(KeyDown(MouseLeft); action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: false, alt_click: false, right_click: false }), + entry!(KeyDown(MouseLeft); modifiers=[Shift], action_dispatch=NodeGraphMessage::PointerDown { shift_click: true, control_click: false, alt_click: false, right_click: false }), + entry!(KeyDown(MouseLeft); modifiers=[Accel], action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: true, alt_click: false, right_click: false }), + entry!(KeyDown(MouseLeft); modifiers=[Shift, Accel], action_dispatch=NodeGraphMessage::PointerDown { shift_click: true, control_click: true, alt_click: false, right_click: false }), + entry!(KeyDown(MouseLeft); modifiers=[Alt], action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: false, alt_click: true, right_click: false }), + entry!(KeyDown(MouseRight); action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: false, alt_click: false, right_click: true }), entry!(DoubleClick(MouseButton::Left); action_dispatch=NodeGraphMessage::EnterNestedNetwork), - entry!(PointerMove; refresh_keys=[Shift], action_dispatch=NodeGraphMessage::PointerMove {shift: Shift}), + entry!(PointerMove; refresh_keys=[Shift], action_dispatch=NodeGraphMessage::PointerMove { shift: Shift }), + entry!(PointerShake; action_dispatch=NodeGraphMessage::ShakeNode), entry!(KeyUp(MouseLeft); action_dispatch=NodeGraphMessage::PointerUp), entry!(KeyDown(Delete); modifiers=[Accel], action_dispatch=NodeGraphMessage::DeleteSelectedNodes { delete_children: false }), entry!(KeyDown(Backspace); modifiers=[Accel], action_dispatch=NodeGraphMessage::DeleteSelectedNodes { delete_children: false }), @@ -417,7 +418,7 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(Tab); modifiers=[Control], action_dispatch=PortfolioMessage::NextDocument), entry!(KeyDown(Tab); modifiers=[Control, Shift], action_dispatch=PortfolioMessage::PrevDocument), entry!(KeyDown(KeyW); modifiers=[Accel], action_dispatch=PortfolioMessage::CloseActiveDocumentWithConfirmation), - entry!(KeyDown(KeyW); modifiers=[Accel,Alt], action_dispatch=PortfolioMessage::CloseAllDocumentsWithConfirmation), + entry!(KeyDown(KeyW); modifiers=[Accel, Alt], action_dispatch=PortfolioMessage::CloseAllDocumentsWithConfirmation), entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=PortfolioMessage::OpenDocument), entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=PortfolioMessage::Import), entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=PortfolioMessage::Cut { clipboard: Clipboard::Device }), @@ -440,7 +441,7 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(Space); modifiers=[Shift], action_dispatch=AnimationMessage::ToggleLivePreview), entry!(KeyDown(Home); modifiers=[Shift], action_dispatch=AnimationMessage::RestartAnimation), ]; - let (mut key_up, mut key_down, mut key_up_no_repeat, mut key_down_no_repeat, mut double_click, mut wheel_scroll, mut pointer_move) = mappings; + let (mut key_up, mut key_down, mut key_up_no_repeat, mut key_down_no_repeat, mut double_click, mut wheel_scroll, mut pointer_move, mut pointer_shake) = mappings; let sort = |list: &mut KeyMappingEntries| list.0.sort_by(|a, b| b.modifiers.count_ones().cmp(&a.modifiers.count_ones())); // Sort the sublists of `key_up`, `key_down`, `key_up_no_repeat`, and `key_down_no_repeat` @@ -457,6 +458,8 @@ pub fn input_mappings() -> Mapping { sort(&mut wheel_scroll); // Sort `pointer_move` sort(&mut pointer_move); + // Sort `pointer_shake` + sort(&mut pointer_shake); Mapping { key_up, @@ -466,6 +469,7 @@ pub fn input_mappings() -> Mapping { double_click, wheel_scroll, pointer_move, + pointer_shake, } } diff --git a/editor/src/messages/input_mapper/utility_types/macros.rs b/editor/src/messages/input_mapper/utility_types/macros.rs index 3fabbbeb2d..11ad50dace 100644 --- a/editor/src/messages/input_mapper/utility_types/macros.rs +++ b/editor/src/messages/input_mapper/utility_types/macros.rs @@ -90,6 +90,7 @@ macro_rules! mapping { let mut double_click = KeyMappingEntries::mouse_buttons_arrays(); let mut wheel_scroll = KeyMappingEntries::new(); let mut pointer_move = KeyMappingEntries::new(); + let mut pointer_shake = KeyMappingEntries::new(); $( // Each of the many entry slices, one specified per action @@ -104,6 +105,7 @@ macro_rules! mapping { InputMapperMessage::DoubleClick(key) => &mut double_click[key as usize], InputMapperMessage::WheelScroll => &mut wheel_scroll, InputMapperMessage::PointerMove => &mut pointer_move, + InputMapperMessage::PointerShake => &mut pointer_shake, }; // Push each entry to the corresponding `KeyMappingEntries` list for its input type corresponding_list.push(entry.clone()); @@ -111,7 +113,7 @@ macro_rules! mapping { } )* - (key_up, key_down, key_up_no_repeat, key_down_no_repeat, double_click, wheel_scroll, pointer_move) + (key_up, key_down, key_up_no_repeat, key_down_no_repeat, double_click, wheel_scroll, pointer_move, pointer_shake) }}; } diff --git a/editor/src/messages/input_mapper/utility_types/misc.rs b/editor/src/messages/input_mapper/utility_types/misc.rs index 0c9a220529..7733d6a27f 100644 --- a/editor/src/messages/input_mapper/utility_types/misc.rs +++ b/editor/src/messages/input_mapper/utility_types/misc.rs @@ -14,6 +14,7 @@ pub struct Mapping { pub double_click: [KeyMappingEntries; NUMBER_OF_MOUSE_BUTTONS], pub wheel_scroll: KeyMappingEntries, pub pointer_move: KeyMappingEntries, + pub pointer_shake: KeyMappingEntries, } impl Default for Mapping { @@ -47,6 +48,7 @@ impl Mapping { InputMapperMessage::DoubleClick(key) => &self.double_click[*key as usize], InputMapperMessage::WheelScroll => &self.wheel_scroll, InputMapperMessage::PointerMove => &self.pointer_move, + InputMapperMessage::PointerShake => &self.pointer_shake, } } @@ -59,6 +61,7 @@ impl Mapping { InputMapperMessage::DoubleClick(key) => &mut self.double_click[*key as usize], InputMapperMessage::WheelScroll => &mut self.wheel_scroll, InputMapperMessage::PointerMove => &mut self.pointer_move, + InputMapperMessage::PointerShake => &mut self.pointer_shake, } } } diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs index 53347542fc..dcfbac728a 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs @@ -12,6 +12,7 @@ pub enum InputPreprocessorMessage { PointerDown { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, PointerMove { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, PointerUp { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, + PointerShake { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, CurrentTime { timestamp: u64 }, WheelScroll { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, } diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs index 16f9dca9cf..1a8962d974 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs @@ -97,6 +97,14 @@ impl MessageHandler f self.translate_mouse_event(mouse_state, false, responses); } + InputPreprocessorMessage::PointerShake { editor_mouse_state, modifier_keys } => { + self.update_states_of_modifier_keys(modifier_keys, keyboard_platform, responses); + + let mouse_state = editor_mouse_state.to_mouse_state(&self.viewport_bounds); + self.mouse.position = mouse_state.position; + + responses.add(InputMapperMessage::PointerShake); + } InputPreprocessorMessage::CurrentTime { timestamp } => { responses.add(AnimationMessage::SetTime { time: timestamp as f64 }); self.time = timestamp; diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs index a730f47cad..3015cec82c 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs @@ -97,6 +97,7 @@ pub enum NodeGraphMessage { PointerOutsideViewport { shift: Key, }, + ShakeNode, RemoveImport { import_index: usize, }, diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index d7391227c9..e742455274 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -1270,6 +1270,45 @@ impl<'a> MessageHandler> for NodeG self.auto_panning.stop(&messages, responses); } } + NodeGraphMessage::ShakeNode => { + // TODO: This does not have the desired behavior yet, this is just a placeholder + + // Only shake if dragging a node + if self.drag_start.is_none() { + return; + } + + let Some(selected_nodes) = network_interface.selected_nodes_in_nested_network(selection_network_path) else { + log::error!("Could not get selected nodes in ShakeNode"); + return; + }; + let selected_node_ids: Vec<_> = selected_nodes.selected_nodes().cloned().collect(); + + // Disconnect all wires from the selected nodes + for &node_id in &selected_node_ids { + // Disconnect input wires + for input_index in 0..network_interface.number_of_inputs(&node_id, selection_network_path) { + let input_connector = InputConnector::node(node_id, input_index); + responses.add(NodeGraphMessage::DisconnectInput { input_connector }); + } + + // Disconnect output wires + let number_of_outputs = network_interface.number_of_outputs(&node_id, selection_network_path); + if let Some(outward_wires) = network_interface.outward_wires(selection_network_path) { + for output_index in 0..number_of_outputs { + let output_connector = OutputConnector::node(node_id, output_index); + if let Some(downstream_connections) = outward_wires.get(&output_connector) { + for &input_connector in downstream_connections { + responses.add(NodeGraphMessage::DisconnectInput { input_connector }); + } + } + } + } + } + + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(NodeGraphMessage::SendGraph); + } NodeGraphMessage::RemoveImport { import_index: usize } => { network_interface.remove_import(usize, selection_network_path); responses.add(NodeGraphMessage::SendGraph); @@ -1785,6 +1824,12 @@ impl NodeGraphMessageHandler { )); } + if self.drag_start.is_some() { + common.extend(actions!(NodeGraphMessageDiscriminant; + ShakeNode, + )); + } + common } diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts index bb7b035ceb..4c61893167 100644 --- a/frontend/src/io-managers/input.ts +++ b/frontend/src/io-managers/input.ts @@ -36,6 +36,8 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli let textToolInteractiveInputElement = undefined as undefined | HTMLDivElement; let canvasFocused = true; let inPointerLock = false; + const shakeSamples: { x: number; y: number; time: number }[] = []; + let lastShakeTime = 0; // Event listeners @@ -159,6 +161,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return; const modifiers = makeKeyboardModifiersBitfield(e); + if (detectShake(e)) editor.handle.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers); editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers); } @@ -331,6 +334,66 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli }); } + function detectShake(e: PointerEvent | MouseEvent): boolean { + const SHAKE_DETECTION_WINDOW_MS = 500; + const SHAKE_DEBOUNCE_MS = 1000; + const SHAKE_DISTANCE_TO_DISPLACEMENT_RATIO = 3; + const SHAKE_DIRECTION_CHANGES = 2; + + // Shake detection + const now = Date.now(); + // Add the current mouse position and time to our list of samples + shakeSamples.push({ x: e.clientX, y: e.clientY, time: now }); + + // Remove samples that are older than our time window + while (shakeSamples.length > 0 && now - shakeSamples[0].time > SHAKE_DETECTION_WINDOW_MS) { + shakeSamples.shift(); + } + + // Check for a shake if we have enough samples and are not currently debouncing a previous shake + if (shakeSamples.length > 3 && now - lastShakeTime > SHAKE_DEBOUNCE_MS) { + // Calculate the total distance traveled + let totalDistance = 0; + for (let i = 1; i < shakeSamples.length; i += 1) { + const p1 = shakeSamples[i - 1]; + const p2 = shakeSamples[i]; + totalDistance += Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); + } + + // Calculate the displacement (the distance between the first and last mouse positions) + const firstPoint = shakeSamples[0]; + const lastPoint = shakeSamples[shakeSamples.length - 1]; + const displacement = Math.sqrt((lastPoint.x - firstPoint.x) ** 2 + (lastPoint.y - firstPoint.y) ** 2); + + // Count the number of times the mouse changes direction significantly + let directionChanges = 0; + for (let i = 1; i < shakeSamples.length - 1; i += 1) { + const p1 = shakeSamples[i - 1]; + const p2 = shakeSamples[i]; + const p3 = shakeSamples[i + 1]; + + const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y }; + const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y }; + + // Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees + if (vector1.x * vector2.x + vector1.y * vector2.y < 0) { + directionChanges += 1; + } + } + + // A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times + if (displacement < totalDistance / SHAKE_DISTANCE_TO_DISPLACEMENT_RATIO && directionChanges >= SHAKE_DIRECTION_CHANGES) { + lastShakeTime = now; + // Clear samples to prevent re-triggering on the same movement + shakeSamples.length = 0; + + return true; + } + } + + return false; + } + // Frontend message subscriptions editor.subscriptions.subscribeJsMessage(TriggerPaste, async () => { diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index c8056e4efc..57c5899e0b 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -384,6 +384,17 @@ impl EditorHandle { self.dispatch(message); } + /// Mouse shaken + #[wasm_bindgen(js_name = onMouseShake)] + pub fn on_mouse_shake(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { + let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::PointerShake { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + /// Mouse double clicked #[wasm_bindgen(js_name = onDoubleClick)] pub fn on_double_click(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { From 8f6dd421679311984adbca80ad6c5dc49aa8cecc Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Jul 2025 19:32:39 -0700 Subject: [PATCH 2/5] Improve shake detection algorithm --- frontend/src/io-managers/input.ts | 91 ++++++++++++++++--------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts index 4c61893167..aee4091c8b 100644 --- a/frontend/src/io-managers/input.ts +++ b/frontend/src/io-managers/input.ts @@ -335,60 +335,65 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli } function detectShake(e: PointerEvent | MouseEvent): boolean { - const SHAKE_DETECTION_WINDOW_MS = 500; - const SHAKE_DEBOUNCE_MS = 1000; - const SHAKE_DISTANCE_TO_DISPLACEMENT_RATIO = 3; - const SHAKE_DIRECTION_CHANGES = 2; + const SENSITIVITY_DIRECTION_CHANGES = 3; + const SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO = 0.1; + const DETECTION_WINDOW_MS = 500; + const DEBOUNCE_MS = 1000; - // Shake detection - const now = Date.now(); // Add the current mouse position and time to our list of samples + const now = Date.now(); shakeSamples.push({ x: e.clientX, y: e.clientY, time: now }); // Remove samples that are older than our time window - while (shakeSamples.length > 0 && now - shakeSamples[0].time > SHAKE_DETECTION_WINDOW_MS) { + while (shakeSamples.length > 0 && now - shakeSamples[0].time > DETECTION_WINDOW_MS) { shakeSamples.shift(); } - // Check for a shake if we have enough samples and are not currently debouncing a previous shake - if (shakeSamples.length > 3 && now - lastShakeTime > SHAKE_DEBOUNCE_MS) { - // Calculate the total distance traveled - let totalDistance = 0; - for (let i = 1; i < shakeSamples.length; i += 1) { - const p1 = shakeSamples[i - 1]; - const p2 = shakeSamples[i]; - totalDistance += Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); - } + // We can't be shaking if it's too early in terms of samples or debounce time + if (shakeSamples.length <= 3 || now - lastShakeTime <= DEBOUNCE_MS) return false; - // Calculate the displacement (the distance between the first and last mouse positions) - const firstPoint = shakeSamples[0]; - const lastPoint = shakeSamples[shakeSamples.length - 1]; - const displacement = Math.sqrt((lastPoint.x - firstPoint.x) ** 2 + (lastPoint.y - firstPoint.y) ** 2); - - // Count the number of times the mouse changes direction significantly - let directionChanges = 0; - for (let i = 1; i < shakeSamples.length - 1; i += 1) { - const p1 = shakeSamples[i - 1]; - const p2 = shakeSamples[i]; - const p3 = shakeSamples[i + 1]; - - const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y }; - const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y }; - - // Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees - if (vector1.x * vector2.x + vector1.y * vector2.y < 0) { - directionChanges += 1; - } - } + // Calculate the total distance traveled + let totalDistanceSquared = 0; + for (let i = 1; i < shakeSamples.length; i += 1) { + const p1 = shakeSamples[i - 1]; + const p2 = shakeSamples[i]; + totalDistanceSquared += (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + } - // A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times - if (displacement < totalDistance / SHAKE_DISTANCE_TO_DISPLACEMENT_RATIO && directionChanges >= SHAKE_DIRECTION_CHANGES) { - lastShakeTime = now; - // Clear samples to prevent re-triggering on the same movement - shakeSamples.length = 0; + // Count the number of times the mouse changes direction significantly, and the average position of the mouse + let directionChanges = 0; + const averagePoint = { x: 0, y: 0 }; + let averagePointCount = 0; + for (let i = 0; i < shakeSamples.length - 2; i += 1) { + const p1 = shakeSamples[i]; + const p2 = shakeSamples[i + 1]; + const p3 = shakeSamples[i + 2]; - return true; - } + const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y }; + const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y }; + + // Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees + if (vector1.x * vector2.x + vector1.y * vector2.y < 0) directionChanges += 1; + + averagePoint.x += p2.x; + averagePoint.y += p2.y; + averagePointCount += 1; + } + if (averagePointCount > 0) { + averagePoint.x /= averagePointCount; + averagePoint.y /= averagePointCount; + } + + // Calculate the displacement (the distance between the first and last mouse positions) + const lastPoint = shakeSamples[shakeSamples.length - 1]; + const displacementSquared = (lastPoint.x - averagePoint.x) ** 2 + (lastPoint.y - averagePoint.y) ** 2; + + // A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times + if (SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO * totalDistanceSquared >= displacementSquared && directionChanges >= SENSITIVITY_DIRECTION_CHANGES) { + lastShakeTime = now; + shakeSamples.length = 0; + + return true; } return false; From 216f6691a94aa23bab90d5dd3f71c6cbccacc976 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 15 Jul 2025 19:48:57 -0700 Subject: [PATCH 3/5] Fix reconnection --- .../node_graph/node_graph_message_handler.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index e742455274..e8e9dacd59 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -1286,6 +1286,8 @@ impl<'a> MessageHandler> for NodeG // Disconnect all wires from the selected nodes for &node_id in &selected_node_ids { + let previous_first_connection = network_interface.upstream_output_connector(&InputConnector::node(node_id, 0), selection_network_path); + // Disconnect input wires for input_index in 0..network_interface.number_of_inputs(&node_id, selection_network_path) { let input_connector = InputConnector::node(node_id, input_index); @@ -1303,6 +1305,18 @@ impl<'a> MessageHandler> for NodeG } } } + if let Some(previous_first_connection) = previous_first_connection { + let Some(downstream_connections_to_first_output) = outward_wires.get(&OutputConnector::node(node_id, 0)) else { + log::error!("Could not get downstream_connections_to_first_output in shake node"); + return; + }; + for downstream_connections_to_first_output in downstream_connections_to_first_output { + responses.add(NodeGraphMessage::CreateWire { + output_connector: previous_first_connection, + input_connector: *downstream_connections_to_first_output, + }); + } + } } } From 1ecb0b1ec7beaa309345efb0253b34fafc1bd47d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 16 Jul 2025 01:36:36 -0700 Subject: [PATCH 4/5] Improve shake reconnect logic --- .../document/node_graph/node_graph_message.rs | 3 + .../node_graph/node_graph_message_handler.rs | 159 ++++++++++++++---- .../utility_types/network_interface.rs | 58 ++++--- 3 files changed, 165 insertions(+), 55 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs index 3015cec82c..fbe3e5e1d5 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs @@ -81,6 +81,9 @@ pub enum NodeGraphMessage { node_id: NodeId, parent: LayerNodeIdentifier, }, + SetChainPosition { + node_id: NodeId, + }, PasteNodes { serialized_nodes: String, }, diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index e8e9dacd59..3ee5010aa4 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -10,7 +10,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{ContextMen use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; use crate::messages::portfolio::document::utility_types::network_interface::{ - self, InputConnector, NodeNetworkInterface, NodeTemplate, NodeTypePersistentMetadata, OutputConnector, Previewing, TypeSource, + self, FlowType, InputConnector, NodeNetworkInterface, NodeTemplate, NodeTypePersistentMetadata, OutputConnector, Previewing, TypeSource, }; use crate::messages::portfolio::document::utility_types::nodes::{CollapsedLayers, LayerPanelEntry}; use crate::messages::portfolio::document::utility_types::wires::{GraphWireStyle, WirePath, WirePathUpdate, build_vector_wire}; @@ -55,6 +55,8 @@ pub struct NodeGraphMessageHandler { /// If dragging the selected nodes, this stores the starting position both in viewport and node graph coordinates, /// plus a flag indicating if it has been dragged since the mousedown began. pub drag_start: Option<(DragStart, bool)>, + // Store the selected chain nodes on drag start so they can be reconnected if shaken + pub drag_start_chain_nodes: Vec, /// If dragging the background to create a box selection, this stores its starting point in node graph coordinates, /// plus a flag indicating if it has been dragged since the mousedown began. box_selection_start: Option<(DVec2, bool)>, @@ -568,6 +570,9 @@ impl<'a> MessageHandler> for NodeG NodeGraphMessage::MoveNodeToChainStart { node_id, parent } => { network_interface.move_node_to_chain_start(&node_id, parent, selection_network_path); } + NodeGraphMessage::SetChainPosition { node_id } => { + network_interface.set_chain_position(&node_id, selection_network_path); + } NodeGraphMessage::PasteNodes { serialized_nodes } => { let data = match serde_json::from_str::>(&serialized_nodes) { Ok(d) => d, @@ -821,6 +826,20 @@ impl<'a> MessageHandler> for NodeG }; self.drag_start = Some((drag_start, false)); + let selected_chain_nodes = updated_selected + .iter() + .filter(|node_id| network_interface.is_chain(node_id, selection_network_path)) + .copied() + .collect::>(); + self.drag_start_chain_nodes = selected_chain_nodes + .iter() + .flat_map(|selected| { + network_interface + .upstream_flow_back_from_nodes(vec![*selected], selection_network_path, FlowType::PrimaryFlow) + .skip(1) + .filter(|node_id| network_interface.is_chain(node_id, selection_network_path)) + }) + .collect::>(); self.begin_dragging = true; self.node_has_moved_in_drag = false; self.update_node_graph_hints(responses); @@ -1188,6 +1207,7 @@ impl<'a> MessageHandler> for NodeG { return None; } + log::debug!("preferences.graph_wire_style: {:?}", preferences.graph_wire_style); let (wire, is_stack) = network_interface.vector_wire_from_input(&input, preferences.graph_wire_style, selection_network_path)?; wire.rectangle_intersections_exist(bounding_box[0], bounding_box[1]).then_some((input, is_stack)) }) @@ -1273,53 +1293,131 @@ impl<'a> MessageHandler> for NodeG NodeGraphMessage::ShakeNode => { // TODO: This does not have the desired behavior yet, this is just a placeholder - // Only shake if dragging a node - if self.drag_start.is_none() { + let Some(drag_start) = &self.drag_start else { + log::error!("Drag start should be initialized when shaking a node"); return; - } + }; + + let Some(network_metadata) = network_interface.network_metadata(selection_network_path) else { + return; + }; + + let viewport_location = ipp.mouse.position; + let point = network_metadata + .persistent_metadata + .navigation_metadata + .node_graph_to_viewport + .inverse() + .transform_point2(viewport_location); + + // Collect the distance to move the shaken nodes after the undo + let graph_delta = IVec2::new(((point.x - drag_start.0.start_x) / 24.).round() as i32, ((point.y - drag_start.0.start_y) / 24.).round() as i32); + + // Undo to the state of the graph before shaking + responses.add(DocumentMessage::AbortTransaction); + + // Add a history step to undo to the state before shaking + responses.add(DocumentMessage::AddTransaction); let Some(selected_nodes) = network_interface.selected_nodes_in_nested_network(selection_network_path) else { log::error!("Could not get selected nodes in ShakeNode"); return; }; - let selected_node_ids: Vec<_> = selected_nodes.selected_nodes().cloned().collect(); - // Disconnect all wires from the selected nodes - for &node_id in &selected_node_ids { - let previous_first_connection = network_interface.upstream_output_connector(&InputConnector::node(node_id, 0), selection_network_path); + let mut all_selected_nodes = selected_nodes.0.iter().copied().collect::>(); + for selected_layer in selected_nodes + .0 + .iter() + .filter(|selected_node| network_interface.is_layer(selected_node, selection_network_path)) + .copied() + .collect::>() + { + for sole_dependent in network_interface.upstream_nodes_below_layer(&selected_layer, selection_network_path) { + all_selected_nodes.insert(sole_dependent); + } + } - // Disconnect input wires - for input_index in 0..network_interface.number_of_inputs(&node_id, selection_network_path) { - let input_connector = InputConnector::node(node_id, input_index); - responses.add(NodeGraphMessage::DisconnectInput { input_connector }); + for selected_node in &all_selected_nodes { + // Handle inputs of selected node + for input_index in 0..network_interface.number_of_inputs(selected_node, selection_network_path) { + let input_connector = InputConnector::node(*selected_node, input_index); + // Only disconnect inputs to non selected nodes + if network_interface + .upstream_output_connector(&input_connector, selection_network_path) + .and_then(|connector| connector.node_id()) + .is_some_and(|node_id| !all_selected_nodes.contains(&node_id)) + { + responses.add(NodeGraphMessage::DisconnectInput { input_connector }); + } } - // Disconnect output wires - let number_of_outputs = network_interface.number_of_outputs(&node_id, selection_network_path); - if let Some(outward_wires) = network_interface.outward_wires(selection_network_path) { - for output_index in 0..number_of_outputs { - let output_connector = OutputConnector::node(node_id, output_index); - if let Some(downstream_connections) = outward_wires.get(&output_connector) { - for &input_connector in downstream_connections { + let number_of_outputs = network_interface.number_of_outputs(selected_node, selection_network_path); + let first_deselected_upstream_node = network_interface + .upstream_flow_back_from_nodes(vec![*selected_node], selection_network_path, FlowType::PrimaryFlow) + .find(|upstream_node| !all_selected_nodes.contains(upstream_node)); + let Some(outward_wires) = network_interface.outward_wires(selection_network_path) else { + log::error!("Could not get output wires in shake input"); + continue; + }; + + // Disconnect output wires to non selected nodes + for output_index in 0..number_of_outputs { + let output_connector = OutputConnector::node(*selected_node, output_index); + if let Some(downstream_connections) = outward_wires.get(&output_connector) { + for &input_connector in downstream_connections { + if input_connector.node_id().is_some_and(|downstream_node| !all_selected_nodes.contains(&downstream_node)) { responses.add(NodeGraphMessage::DisconnectInput { input_connector }); } } } - if let Some(previous_first_connection) = previous_first_connection { - let Some(downstream_connections_to_first_output) = outward_wires.get(&OutputConnector::node(node_id, 0)) else { - log::error!("Could not get downstream_connections_to_first_output in shake node"); - return; - }; - for downstream_connections_to_first_output in downstream_connections_to_first_output { - responses.add(NodeGraphMessage::CreateWire { - output_connector: previous_first_connection, - input_connector: *downstream_connections_to_first_output, - }); + } + + // Handle reconnection + // Find first non selected upstream node by primary flow + if let Some(first_deselected_upstream_node) = first_deselected_upstream_node { + let Some(downstream_connections_to_first_output) = outward_wires.get(&OutputConnector::node(*selected_node, 0)).cloned() else { + log::error!("Could not get downstream_connections_to_first_output in shake node"); + return; + }; + // Reconnect only if all downstream outputs are not selected + if !downstream_connections_to_first_output + .iter() + .any(|connector| connector.node_id().is_some_and(|node_id| all_selected_nodes.contains(&node_id))) + { + // Find what output on the deselected upstream node to reconnect to + for output_index in 0..network_interface.number_of_outputs(&first_deselected_upstream_node, selection_network_path) { + let output_connector = &OutputConnector::node(first_deselected_upstream_node, output_index); + let Some(outward_wires) = network_interface.outward_wires(selection_network_path) else { + log::error!("Could not get output wires in shake input"); + continue; + }; + if let Some(inputs) = outward_wires.get(output_connector) { + // This can only run once + if inputs.iter().any(|input_connector| { + input_connector + .node_id() + .is_some_and(|upstream_node| all_selected_nodes.contains(&upstream_node) && input_connector.input_index() == 0) + }) { + // Output index is the output of the deselected upstream node to reconnect to + for downstream_connections_to_first_output in &downstream_connections_to_first_output { + responses.add(NodeGraphMessage::CreateWire { + output_connector: OutputConnector::node(first_deselected_upstream_node, output_index), + input_connector: *downstream_connections_to_first_output, + }); + } + } + } + + // Set all chain nodes back to chain position + // TODO: Fix + // for chain_node_to_reset in std::mem::take(&mut self.drag_start_chain_nodes) { + // responses.add(NodeGraphMessage::SetChainPosition { node_id: chain_node_to_reset }); + // } } } } } - + responses.add(NodeGraphMessage::ShiftSelectedNodesByAmount { graph_delta, rubber_band: false }); responses.add(NodeGraphMessage::RunDocumentGraph); responses.add(NodeGraphMessage::SendGraph); } @@ -2618,6 +2716,7 @@ impl Default for NodeGraphMessageHandler { node_has_moved_in_drag: false, shift_without_push: false, box_selection_start: None, + drag_start_chain_nodes: Vec::new(), selection_before_pointer_down: Vec::new(), disconnecting: None, initial_disconnecting: false, diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 404d3486a0..6ab496e367 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -5085,12 +5085,45 @@ impl NodeNetworkInterface { else { log::error!("Could not set chain position for layer node {node_id}"); } + // let previous_upstream_node = self.upstream_output_connector(&InputConnector::node(*node_id, 0), network_path).and_then(|output| output.node_id()); + // let Some(previous_upstream_node_position) = previous_upstream_node.and_then(|upstream| self.position_from_downstream_node(&upstream, network_path)) else { + // log::error!("Could not get previous_upstream_node_position"); + // return; + // }; self.unload_upstream_node_click_targets(vec![*node_id], network_path); // Reload click target of the layer which encapsulate the chain if let Some(downstream_layer) = self.downstream_layer(node_id, network_path) { self.unload_node_click_targets(&downstream_layer.to_node(), network_path); } self.unload_all_nodes_bounding_box(network_path); + + // let Some(new_upstream_node_position) = previous_upstream_node.and_then(|upstream| self.position_from_downstream_node(&upstream, network_path)) else { + // log::error!("Could not get new_upstream_node_position"); + // return; + // }; + // if let Some(previous_upstream_node) = { + // let x_delta = new_upstream_node_position.x - previous_upstream_node_position.x; + // // Upstream node got shifted to left, so shift all upstream absolute sole dependents + // if x_delta != 0 { + // let upstream_absolute_nodes = SelectedNodes( + // self.upstream_flow_back_from_nodes(vec![previous_upstream_node], network_path, FlowType::UpstreamFlow) + // .into_iter() + // .filter(|node_id| self.is_absolute(node_id, network_path)) + // .collect::>(), + // ); + // let old_selected_nodes = std::mem::replace(self.selected_nodes_mut(network_path).unwrap(), upstream_absolute_nodes); + // if x_delta < 0 { + // for _ in 0..x_delta.abs() { + // self.shift_selected_nodes(Direction::Left, false, network_path); + // } + // } else { + // for _ in 0..x_delta.abs() { + // self.shift_selected_nodes(Direction::Right, false, network_path); + // } + // } + // let _ = std::mem::replace(self.selected_nodes_mut(network_path).unwrap(), old_selected_nodes); + // } + // } } fn valid_upstream_chain_nodes(&mut self, input_connector: &InputConnector, network_path: &[NodeId]) -> Vec { @@ -5947,31 +5980,6 @@ impl NodeNetworkInterface { self.create_wire(&OutputConnector::node(*node_id, 0), &InputConnector::node(parent.to_node(), 1), network_path); self.set_chain_position(node_id, network_path); } else { - // TODO: Implement a more robust horizontal shift system when inserting a node into a chain. - // This should be done by breaking the chain and shifting the sole dependents for each node upstream of the insertion. - // Before inserting the node, shift the layer right 7 units so that all sole dependents are also shifted - // let input_connector = InputConnector::node(parent.to_node(), 0); - // let old_upstream = self.upstream_output_connector(&input_connector, network_path); - // This also needs to disconnect from the downstream layer - // self.disconnect_input(&input_connector, network_path); - // let Some(selected_nodes) = self.selected_nodes_mut(network_path) else { - // log::error!("Could not get selected nodes in move_layer_to_stack"); - // return; - // }; - // let old_selected_nodes = selected_nodes.replace_with(vec![parent.to_node()]); - - // for _ in 0..7 { - // self.shift_selected_nodes(Direction::Left, false, network_path); - // } - // // Grip drag it back to the right - // for _ in 0..7 { - // self.shift_selected_nodes(Direction::Right, true, network_path); - // } - // let _ = self.selected_nodes_mut(network_path).unwrap().replace_with(old_selected_nodes); - // if let Some(old_upstream) = old_upstream { - // self.create_wire(&old_upstream, &input_connector, network_path); - // } - // Insert the node in the gap and set the upstream to a chain self.insert_node_between(node_id, &InputConnector::node(parent.to_node(), 1), 0, network_path); self.force_set_upstream_to_chain(node_id, network_path); From 620b6414b9d61c0b30d7bd3ea90f4179ff52e07a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 16 Jul 2025 19:06:18 -0700 Subject: [PATCH 5/5] Fix history --- .../document/node_graph/node_graph_message_handler.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index 3ee5010aa4..3bed174a3c 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -1291,8 +1291,6 @@ impl<'a> MessageHandler> for NodeG } } NodeGraphMessage::ShakeNode => { - // TODO: This does not have the desired behavior yet, this is just a placeholder - let Some(drag_start) = &self.drag_start else { log::error!("Drag start should be initialized when shaking a node"); return; @@ -1316,8 +1314,8 @@ impl<'a> MessageHandler> for NodeG // Undo to the state of the graph before shaking responses.add(DocumentMessage::AbortTransaction); - // Add a history step to undo to the state before shaking - responses.add(DocumentMessage::AddTransaction); + // Add a history step to abort to the state before shaking if right clicked + responses.add(DocumentMessage::StartTransaction); let Some(selected_nodes) = network_interface.selected_nodes_in_nested_network(selection_network_path) else { log::error!("Could not get selected nodes in ShakeNode");