From bc5a2b8fa76a63d93b6000e92c5d445d670e6f42 Mon Sep 17 00:00:00 2001 From: Natnael-Dev Date: Sat, 17 Oct 2026 21:45:51 +0300 Subject: [PATCH] fix(proto): delete prototype interaction without deleting component (#767) When an interaction noodle or interaction editor was selected in the Prototype tab, pressing Delete or Backspace (or executing edit.delete) targeted the selected layer/component instead of deleting the prototype connection. - Intercept Backspace and Delete in shortcuts::key_event and run_binding when an interaction is selected in the Prototype tab to remove the interaction using proto.interaction.remove rather than deleting the layer. - Ensure clicking on empty canvas or outside noodles/handles deselects the active interaction noodle/editor. - Close the interaction editor when navigating away from the Prototype tab. - Add regression tests in proto_editor_tests verifying interaction deletion with Delete/Backspace keeps the component intact, and Escape closes the interaction editor before deselecting the component. --- crates/ui-egui/src/proto_canvas.rs | 3 ++ crates/ui-egui/src/proto_editor_tests.rs | 61 ++++++++++++++++++++++++ crates/ui-egui/src/proto_panel.rs | 43 +++++++++++++++-- crates/ui-egui/src/right.rs | 3 ++ crates/ui-egui/src/shortcuts.rs | 20 ++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 6310c05c..1187944d 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -272,6 +272,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { crate::proto_panel::open_editor(&ctx, Some((n.source, n.index))); return true; } + if pressed && crate::proto_panel::editor(&ctx).is_some() { + crate::proto_panel::open_editor(&ctx, None); + } false } diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index 2372c20e..31988a35 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -226,3 +226,64 @@ fn the_presenter_inspects_the_layer_under_the_pointer() { assert_eq!(presenter(&u)["inspected"], json!(a)); assert_eq!(u.app.inspect()["presentOptions"]["inspect"], json!(true)); } + +#[test] +fn deleting_a_prototype_interaction_removes_the_interaction_not_the_component() { + let (s, _a, b, btn) = setup(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + + assert!(u.app.session.doc().unwrap().node(btn).is_some()); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1); + assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0))); + + // Pressing Delete when an interaction is selected should delete the interaction, NOT the component. + u.key(pos2(100.0, 100.0), egui::Key::Delete); + + assert!(u.app.session.doc().unwrap().node(btn).is_some(), "Bug #767 regression: component was deleted when deleting prototype interaction!"); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0, "the interaction should be removed"); + assert_eq!(crate::proto_panel::editor(&u.ctx), None); + + // Re-add interaction and test Backspace as well. + u.run("proto.interaction.add", json!({"id": btn, "dest": b})); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + u.key(pos2(100.0, 100.0), egui::Key::Backspace); + assert!(u.app.session.doc().unwrap().node(btn).is_some()); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0); + assert_eq!(crate::proto_panel::editor(&u.ctx), None); + + // When NO interaction is active, Delete deletes the component as normal. + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert!(u.app.session.doc().unwrap().node(btn).is_none(), "when no interaction is selected, Delete removes the layer"); +} + +#[test] +fn escape_closes_prototype_editor_without_deselecting_layer() { + let (s, a, _b, btn) = setup(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + + assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0))); + assert_eq!(u.app.session.selection(), vec![btn]); + + // First Escape closes the interaction editor; layer remains selected. + u.key(pos2(100.0, 100.0), egui::Key::Escape); + assert_eq!(crate::proto_panel::editor(&u.ctx), None); + assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected"); + + // Second Escape steps out to parent frame. + u.key(pos2(100.0, 100.0), egui::Key::Escape); + assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame"); + + // Third Escape deselects all. + u.key(pos2(100.0, 100.0), egui::Key::Escape); + assert!(u.app.session.selection().is_empty(), "selection cleared on third escape"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 47312e99..20546008 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -40,6 +40,44 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> { ctx.data(|d| d.get_temp(editor_id())) } +/// Whether an interaction is selected (its editor is open). +pub fn has_selected_interaction(app: &FigApp, ctx: &egui::Context) -> bool { + let Some((node, index)) = editor(ctx) else { return false }; + let Ok(ds) = app.session.ds() else { return false }; + ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_some() +} + +/// Delete the selected interaction (when one is active in the Prototype tab). +/// Returns true if an interaction was deleted. +pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> bool { + if app.ui.right_tab != crate::RightTab::Prototype { + return false; + } + let Some((node, index)) = editor(ctx) else { return false }; + let Ok(ds) = app.session.ds() else { return false }; + if ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_none() { + open_editor(ctx, None); + return false; + } + open_editor(ctx, None); + let ids = targets_for(app, node); + app.run("proto.interaction.remove", json!({"ids": ids, "index": index})); + true +} + +/// Close the interaction editor if open. Returns true if it was open. +pub fn close_editor(ctx: &egui::Context) -> bool { + if capturing(ctx) || crate::variable_ui::picker_open(ctx) { + return false; + } + if editor(ctx).is_some() { + open_editor(ctx, None); + true + } else { + false + } +} + const TRIGGERS: [&str; 10] = [ tr_noop("On click"), tr_noop("On drag"), @@ -607,8 +645,6 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod #[allow(clippy::too_many_arguments)] fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) { let t = Tokens::get(ctx); - // Escape in a field (it cancels the edit) doesn't close the editor. - let typing = ctx.egui_wants_keyboard_input(); let width = 280.0; let screen = ctx.content_rect(); let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); @@ -652,8 +688,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N }); }); }); - let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !typing && !capturing(ctx) && !crate::variable_ui::picker_open(ctx); - if close || escape { + if close { open_editor(ctx, None); } } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 47f3e5d3..3e2d70c3 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -135,6 +135,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { } ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary }); if resp.clicked() { + if app.ui.right_tab != tab { + crate::proto_panel::open_editor(ui.ctx(), None); + } app.ui.right_tab = tab; } x = r.max.x + 2.0; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 09b2c57b..2408b8f1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -210,6 +210,11 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { pub fn run_binding(app: &mut FigApp, id: &str) { match id { "select.none" => { + if let Some(ctx) = &app.ui_context { + if crate::proto_panel::close_editor(ctx) { + return; + } + } if let Err(e) = app.session.escape() { app.toast(e.to_string()); } @@ -218,6 +223,14 @@ pub fn run_binding(app: &mut FigApp, id: &str) { "edit.delete" if app.session.tool.pen.is_some() => { let _ = app.session.pen_finish(); } + "edit.delete" + if app.ui.right_tab == crate::RightTab::Prototype + && app.ui_context.as_ref().is_some_and(|ctx| crate::proto_panel::has_selected_interaction(app, ctx)) => + { + if let Some(ctx) = app.ui_context.clone() { + let _ = crate::proto_panel::delete_selected_interaction(app, &ctx); + } + } // Enter with a shape or frame tool chosen places one in the middle of the view. "select.children" if app.session.tool.tool.draws_shape() => { app.run("tool.place", json!({})); @@ -228,6 +241,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) { } fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool { + if key == Key::Escape && crate::proto_panel::close_editor(ctx) { + return true; + } if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) { return true; } @@ -238,6 +254,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) { return true; } + // An interaction selected in the Prototype tab: Delete removes that interaction, not the component. + if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && crate::proto_panel::delete_selected_interaction(app, ctx) { + return true; + } // Vector edit mode and the shape builder have their own keys (tools, nudging points, delete, // join, Enter to finish); the rest fall through. match app.session.tool_key(key.name(), m.shift, m.alt, m.command) {