mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user