diff --git a/ROADMAP.md b/ROADMAP.md index 2ed6f8c9..065dcb38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -182,6 +182,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | | 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) | | 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) | +| 2026-10-24 | Edit Points: Esc pressed before the mouse button is released cancels a point or handle drag, as in PowerPoint, and leaves the shape as it was with no undo step. Edit Points stays on after the cancel | | 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come | | 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) | | 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) | diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 8f2932bf..b164dbd6 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -54,6 +54,9 @@ impl Session { let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty()); let k = key_token(key); let k = k.as_str(); + if matches!(k, "escape" | "esc") && self.cancel_point_drag() { + return Ok(json!({"handled": true})); + } if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") { return self.finish_freeform(false); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3611cedb..fa664627 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2293,6 +2293,51 @@ fn edit_points_with_the_pointer_and_curves() { assert!(s.tool.edit_points.is_none()); } +/// Esc before the button is released cancels a point or handle drag in Edit Points: the shape +/// is as it was at the press, no undo step is added, and Edit Points stays on. +#[test] +fn edit_points_escape_cancels_a_point_or_handle_drag() { + use crate::tools::{Mods, PointerEvent, PointerKind}; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let id = deckcraft_model::ShapeId(s.execute("shape.points", &json!({})).unwrap()["id"].as_u64().unwrap() as u32); + let path = |s: &Session| s.doc().unwrap().shape(id).map(|sh| format!("{:?}", sh.geom)).unwrap_or_default(); + let undo_len = |s: &Session| s.doc().unwrap().history.undo.len(); + let ev = |kind, x: f64, y: f64| PointerEvent { kind, x, y, mods: Mods::default(), ..Default::default() }; + let (before, steps) = (path(&s), undo_len(&s)); + // A vertex: drag it, press Esc, keep dragging, release. + let v: Vec<[f64; 2]> = serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap(); + let [x0, y0] = v[0]; + s.pointer(ev(PointerKind::Down, x0, y0)).unwrap(); + s.pointer(ev(PointerKind::Drag, x0 - 30.0, y0)).unwrap(); + assert_ne!(path(&s), before, "the drag moves the point"); + assert_eq!(s.key("Escape", Mods::default()).unwrap()["handled"], true); + assert_eq!(path(&s), before, "Esc puts the point back"); + assert!(!s.tool.dragging()); + s.pointer(ev(PointerKind::Drag, x0 - 50.0, y0)).unwrap(); + s.pointer(ev(PointerKind::Up, x0 - 50.0, y0)).unwrap(); + assert_eq!(path(&s), before, "the rest of the drag does nothing"); + assert_eq!(undo_len(&s), steps, "no undo step"); + assert_eq!(s.tool.editing_points(s.doc().unwrap()), Some(id), "Edit Points stays on"); + // A handle of the same point, which the press on the point shows. + let st = s.doc().unwrap(); + let (_, ho, _) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 0).unwrap(); + let ho = ho.unwrap(); + s.pointer(ev(PointerKind::Down, ho.x, ho.y)).unwrap(); + s.pointer(ev(PointerKind::Drag, ho.x + 20.0, ho.y + 20.0)).unwrap(); + assert_ne!(path(&s), before, "the drag moves the handle"); + s.key("Escape", Mods::default()).unwrap(); + s.pointer(ev(PointerKind::Up, ho.x + 20.0, ho.y + 20.0)).unwrap(); + assert_eq!(path(&s), before, "Esc puts the handle back"); + assert_eq!(undo_len(&s), steps, "no undo step"); + assert_eq!(s.tool.editing_points(s.doc().unwrap()), Some(id), "Edit Points stays on"); + // Without a drag, Esc still leaves Edit Points. + s.key("Escape", Mods::default()).unwrap(); + assert!(s.tool.edit_points.is_none()); +} + #[test] fn edit_points_path_parse_and_write() { use crate::cmd::points::{Seg, parse, vertices, write}; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 5161d649..fabfece6 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -542,6 +542,25 @@ impl Session { } } + /// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the + /// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled. + pub(crate) fn cancel_point_drag(&mut self) -> bool { + if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) { + return false; + } + self.tool.drag = None; + if let Some(st) = self.active_mut() + && st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points") + && let Some(i) = st.interaction.take() + && !Arc::ptr_eq(&i.doc, &st.doc) + { + st.doc = i.doc; + st.selection = i.selection; + st.revision += 1; + } + true + } + /// End the interaction only when it is the one `label` started. A twist must not commit a drag. pub(crate) fn commit_named(&mut self, label: &str) { let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label);