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.
This commit is contained in:
Natnael-Dev
2026-10-17 21:45:51 +03:00
parent 0a01188c31
commit bc5a2b8fa7
5 changed files with 126 additions and 4 deletions
+3
View File
@@ -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
}
+61
View File
@@ -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");
}
+39 -4
View File
@@ -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);
}
}
+3
View File
@@ -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;
+20
View File
@@ -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<Key>, 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<Key>, 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) {