From 1499834de7c3015632db03a0e5ad4aaa9f90b34a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 23:29:42 +0700 Subject: [PATCH] Fix #85: empty point type placed by a click is discarded when editing ends Clicking with the Type tool runs text.create at once, and ending the edit (Escape, another tool, a click elsewhere) only stopped editing, so every stray click left an invisible empty text object and a "Type" history step. The Type tool now remembers the point type its click placed and, when editing ends with it still empty, runs the new text.discardEmpty command: it removes the text and, when nothing else changed since the step that created it, drops those steps so neither the document nor the history keeps a trace. A command from outside the tool that takes the edited text out of the selection (Deselect, a Layers panel pick) or removes it now ends the edit too (Tool::after_command). Dragged area-type frames and type in or on a path keep their frame. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/textedit.rs | 37 ++++++ crates/engine/src/lib.rs | 9 ++ crates/engine/src/tests_emptytype.rs | 161 +++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 20 ++++ crates/tools/src/lib.rs | 5 + crates/tools/src/text.rs | 40 +++++-- docs/mcp.md | 13 +++ 7 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/tests_emptytype.rs diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index aa3cd500..57def78b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -58,6 +58,15 @@ pub fn specs() -> Vec { has_selection, fit_headline ), + cmd!( + "text.discardEmpty", + "Discard Empty Type", + [], + None, + "{id} remove text object `id` if it has no characters (what the Type tool does with point type it placed when editing ends). When nothing but that text changed since the step that created it, the steps since are dropped instead, leaving no trace in the history → {removed}", + has_doc, + discard_empty + ), ] } @@ -449,3 +458,31 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "tracking": applied})) } + +fn discard_empty(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.discardEmpty"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + if edit::runs_len(&text_ref(s, id)?.runs) > 0 { + return Ok(json!({"removed": false})); + } + // The newest step whose document lacks the text created it: when the document now is that one + // plus the text (and its id), roll back to it without a trace. + let st = s.doc_mut()?; + if st.interaction.is_none() + && let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none()) + && let Some(created) = st.history.undo.get(k) + { + let mut now = (*st.doc).clone(); + let mut before = (*created.doc).clone(); + before.alloc_id(); + if now.remove(id).is_ok() && now == before { + st.doc = created.doc.clone(); + st.history.undo.truncate(k); + st.selection.prune(&st.doc); + st.revision += 1; + return Ok(json!({"removed": true})); + } + } + s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?; + Ok(json!({"removed": true})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 08b2ac3d..8abfa657 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -668,6 +668,9 @@ pub struct Session { pub(crate) batch_stash: Option>, /// Where Data Recovery keeps its copies ([`cmd::recovery`]). pub recovery: cmd::recovery::Recovery, + /// While the active tool's actions run: their commands aren't "a command from outside the + /// tool" ([`Session::after_command`]). + pub(crate) in_tool_actions: bool, } impl Default for Session { @@ -708,6 +711,7 @@ impl Session { note_depth: 1, batch_stash: None, recovery: Default::default(), + in_tool_actions: false, } } @@ -861,6 +865,9 @@ impl Session { let p = self.noted(params); self.journal.push((id.to_string(), p)); } + if self.depth == 0 { + self.after_command(); + } Ok(r) } @@ -1138,6 +1145,8 @@ mod tests_editcolors; #[cfg(test)] mod tests_effectedit; #[cfg(test)] +mod tests_emptytype; +#[cfg(test)] mod tests_expand; #[cfg(test)] mod tests_eyedropper; diff --git a/crates/engine/src/tests_emptytype.rs b/crates/engine/src/tests_emptytype.rs new file mode 100644 index 00000000..71261fd0 --- /dev/null +++ b/crates/engine/src/tests_emptytype.rs @@ -0,0 +1,161 @@ +//! Point type placed with the Type tool and left empty is discarded when editing ends, without a +//! trace in the history; frames (dragged area type, type in or on a path) stay. + +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind, TextKind}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap(); + if from != to { + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap(); +} + +fn click(s: &mut Session, x: f64, y: f64) { + drag(s, (x, y), (x, y)); +} + +fn key(s: &mut Session, k: ToolKey) { + s.tool_key(k, Mods::default(), ViewInfo::default()).unwrap(); +} + +/// The text objects in the document: (id, kind is point type, plain text). +fn texts(s: &Session) -> Vec<(NodeId, bool, String)> { + let mut v = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push((n.id, matches!(t.kind, TextKind::Point), t.plain_text())); + } + }); + v +} + +fn undo_labels(s: &Session) -> Vec { + s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect() +} + +#[test] +fn clicking_without_typing_leaves_no_text_and_no_history() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + // Click, then Escape. + click(&mut s, 100.0, 100.0); + assert_eq!(texts(&s).len(), 1, "the click places point type to edit"); + assert!(s.tool_wants_text()); + key(&mut s, ToolKey::Escape); + assert!(texts(&s).is_empty()); + // Click, then switch tools. + click(&mut s, 150.0, 100.0); + s.select_tool("rectangle", v).unwrap(); + assert!(texts(&s).is_empty()); + // Click in two places: the first is gone when the second is placed. + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 300.0); + click(&mut s, 400.0, 300.0); + assert_eq!(texts(&s).len(), 1); + s.select_tool("selection", v).unwrap(); + assert!(texts(&s).is_empty()); + assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s)); + let st = s.doc().unwrap(); + assert!(!st.is_dirty() && st.selection.is_empty()); +} + +#[test] +fn typed_text_stays_and_typing_it_away_leaves_nothing() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("Hi", v).unwrap(); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["Hi"]); + let kept = undo_labels(&s); + assert_eq!(kept, ["Type", "Typing"]); + // Typed, the caret moved (a step of its own), then deleted: all of it goes. + click(&mut s, 300.0, 300.0); + s.tool_text("a", v).unwrap(); + key(&mut s, ToolKey::Left); + key(&mut s, ToolKey::Delete); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).len(), 1); + assert_eq!(undo_labels(&s), kept); +} + +#[test] +fn frames_stay_when_left_empty() { + let mut s = session(); + let v = ViewInfo::default(); + // A dragged area-type frame. + s.select_tool("type", v).unwrap(); + drag(&mut s, (100.0, 100.0), (300.0, 200.0)); + key(&mut s, ToolKey::Escape); + assert!(matches!(texts(&s).as_slice(), [(_, false, t)] if t.is_empty())); + // Area type made from a path. + s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 200, "height": 100})).unwrap(); + s.select_tool("areaType", v).unwrap(); + click(&mut s, 500.0, 300.0); + s.select_tool("selection", v).unwrap(); + assert_eq!(texts(&s).len(), 2); + assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Area Type"]); +} + +#[test] +fn a_command_that_deselects_the_text_ends_the_edit() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + // Commands on the edited text (the Character panel) keep editing it. + let id = texts(&s)[0].0; + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 30})).unwrap(); + s.execute("document.inspect", &json!({})).unwrap(); + assert!(s.tool_wants_text()); + s.execute("select.none", &json!({})).unwrap(); + assert!(!s.tool_wants_text()); + assert!(texts(&s).is_empty()); + assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s)); + // A command that edits something else as well: the text goes in a step of its own. + click(&mut s, 100.0, 100.0); + s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 20, "height": 10})).unwrap(); + assert!(!s.tool_wants_text()); + assert!(texts(&s).is_empty()); + assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Discard Empty Type"]); +} + +#[test] +fn switching_documents_discards_it_in_the_document_left() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s.set_active(0); + assert!(texts(&s).is_empty() && undo_labels(&s).is_empty()); +} + +#[test] +fn discard_empty_command() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + assert_eq!(s.execute("text.discardEmpty", &json!({"id": r["id"]})).unwrap(), json!({"removed": false})); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("text.discardEmpty", &json!({"id": rect["id"]})).is_err(), "not text"); + assert!(s.execute("text.discardEmpty", &json!({})).is_err(), "id required"); + // Created by the newest step: that step goes. + let e = s.execute("text.create", &json!({"x": 10, "y": 80, "text": ""})).unwrap(); + assert_eq!(s.execute("text.discardEmpty", &json!({"id": e["id"]})).unwrap(), json!({"removed": true})); + assert_eq!(undo_labels(&s), ["Type", "Rectangle"]); + assert_eq!(texts(&s).len(), 1); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 0f0b2ee4..a8a6cd13 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -164,8 +164,28 @@ impl Session { self.tool.set_option(key, v); } + /// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]): + /// the Type tool stops editing text that is no longer selected. Only while it edits text. + pub(crate) fn after_command(&mut self) { + if self.in_tool_actions || !self.tool.wants_text() { + return; + } + let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx)); + // The command itself succeeded: a failure here only leaves the edit as it was. + if let Err(e) = self.apply_actions(acts) { + log::warn!("ending the text edit: {e}"); + } + } + /// Apply tool actions. Errors from previews are reported but keep the interaction alive. pub fn apply_actions(&mut self, acts: Vec) -> Result> { + let outer = std::mem::replace(&mut self.in_tool_actions, true); + let r = self.apply_tool_actions(acts); + self.in_tool_actions = outer; + r + } + + fn apply_tool_actions(&mut self, acts: Vec) -> Result> { let mut ui = vec![]; for a in acts { match a { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a45e5c69..34e2bdd5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -337,6 +337,11 @@ pub trait Tool: Send { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { vec![] } + /// A command from outside the tool ran (a menu, a panel, an agent): finish work it ended (the + /// Type tool stops editing text the command took out of the selection). + fn after_command(&mut self, _cx: &ToolContext) -> Vec { + vec![] + } } /// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id. diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index f8c77e00..4586943f 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -2,7 +2,8 @@ //! //! Type: click places point type, drag draws an area-type frame, clicking into existing text //! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a -//! baseline (`text.createInPath`). +//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when +//! editing ends (`text.discardEmpty`); frames keep their shape. //! //! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends //! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a @@ -42,6 +43,8 @@ pub struct TypeTool { mode: Mode, /// Text object being edited. editing: Option, + /// The point type a click just placed (edited now): discarded if empty when editing ends. + fresh: Option, /// Caret and selection anchor (byte offsets into the plain text). caret: usize, anchor: usize, @@ -114,8 +117,16 @@ impl TypeTool { if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] } } - fn finish(&mut self) -> Vec { - let out = self.commit(); + /// Stop editing. Point type a click placed that is still empty is discarded, with the steps + /// since its click (`text.discardEmpty`). + fn finish(&mut self, cx: &ToolContext) -> Vec { + let mut out = self.commit(); + if let Some(id) = self.fresh.take() + && self.editing == Some(id) + && Self::text(cx, id).is_some_and(|t| matches!(t.kind, TextKind::Point) && edit::runs_len(&t.runs) == 0) + { + out.push(Action::Exec("text.discardEmpty".into(), json!({"id": id.0}))); + } self.editing = None; self.selecting = false; self.goal_x = None; @@ -170,6 +181,7 @@ impl TypeTool { fn start_editing(&mut self, id: NodeId, caret: usize) { self.editing = Some(id); + self.fresh = None; self.caret = caret; self.anchor = caret; self.goal_x = None; @@ -206,7 +218,7 @@ impl TypeTool { } fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec { - let mut out = self.finish(); + let mut out = self.finish(cx); // Click into existing text: place the caret. if let Some(h) = hit_test(cx.doc, start, cx.hit_options()) && let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) @@ -390,7 +402,7 @@ impl Tool for TypeTool { self.goal_x = None; } match key { - ToolKey::Escape => self.finish(), + ToolKey::Escape => self.finish(cx), ToolKey::Enter => self.replace(cx, "\n", None), ToolKey::Tab => self.replace(cx, "\t", None), ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None), @@ -454,8 +466,21 @@ impl Tool for TypeTool { _ => vec![], } } - fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - self.finish() + fn deactivate(&mut self, cx: &ToolContext) -> Vec { + self.finish(cx) + } + /// Editing ends when a command takes the text out of the selection (Deselect, another object + /// picked in the Layers panel) or removes it. + fn after_command(&mut self, cx: &ToolContext) -> Vec { + let Some(id) = self.editing else { return vec![] }; + let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some()); + while let Some(c) = cur { + if cx.selection.contains(c) { + return vec![]; + } + cur = cx.doc.parent_of(c); + } + self.finish(cx) } /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. fn notify(&mut self, cx: &ToolContext, what: &str) { @@ -463,6 +488,7 @@ impl Tool for TypeTool { && let Some(id) = cx.selection.objects.first().copied() { self.start_editing(id, 0); + self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id); } } /// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`. diff --git a/docs/mcp.md b/docs/mcp.md index fda3080b..de7d4d78 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1654,3 +1654,16 @@ square to the page. `object.resetBoundingBox` squares the box again without movi ```json {"name":"run_command","arguments":{"command":"object.rotate","params":{"angle":45,"absolute":true}}} ``` + +## Empty point type + +Point type the Type tool places with a click and leaves empty is discarded when editing ends: Escape, another tool, +a click elsewhere, switching documents, or a command that takes the text out of the selection (`select.none`, a +`select.set` of other objects). The steps since the click go with it (`text.discardEmpty {id}`), so the document and +its undo history are as if the click never happened. Commands on the edited text (the Character panel's +`text.setRangeStyle {id}`) keep it in editing. Area type dragged as a frame and type in or on a path keep their frame +(Object → Path → Clean Up removes empty text). + +```json +{"name":"run_command","arguments":{"command":"text.discardEmpty","params":{"id":42}}} +```