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 <noreply@anthropic.com>
This commit is contained in:
parent
8adc629055
commit
1499834de7
|
|
@ -58,6 +58,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<Value> {
|
|||
})?;
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "tracking": applied}))
|
||||
}
|
||||
|
||||
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -668,6 +668,9 @@ pub struct Session {
|
|||
pub(crate) batch_stash: Option<Vec<DocState>>,
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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::<Vec<_>>(), ["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);
|
||||
}
|
||||
|
|
@ -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<Action>) -> Result<Vec<UiRequest>> {
|
||||
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<Action>) -> Result<Vec<UiRequest>> {
|
||||
let mut ui = vec![];
|
||||
for a in acts {
|
||||
match a {
|
||||
|
|
|
|||
|
|
@ -337,6 +337,11 @@ pub trait Tool: Send {
|
|||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
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<Action> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id.
|
||||
|
|
|
|||
|
|
@ -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<NodeId>,
|
||||
/// The point type a click just placed (edited now): discarded if empty when editing ends.
|
||||
fresh: Option<NodeId>,
|
||||
/// 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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
self.finish()
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
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<Action> {
|
||||
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`.
|
||||
|
|
|
|||
13
docs/mcp.md
13
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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue