Type: Undo and Redo in a command batch put the caret where the step was

The Type tool heard of Undo and Redo only when they ran as top-level
commands. As steps of command.batch, they left the caret where it was,
and the tool took the batch for an outside edit. Session::execute now
tells the active tool about each nested edit.undo and edit.redo.

command.batch also tells the tool when it begins, ends or rolls back.
The Type tool keeps its caret and its edit records at the start of each
batch and puts them back when a failed batch rolls the documents back.
This commit is contained in:
dotCooCoo
2026-10-29 10:10:58 +07:00
committed by bflatastic
parent c3af6605ef
commit 43ecaaa62a
6 changed files with 93 additions and 2 deletions
+4
View File
@@ -27,15 +27,19 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let active = s.active;
s.batch_stash = Some(vec![]);
let mut grouped = Vec::with_capacity(cmds.len());
// The Type tool keeps what the steps' Undo and Redo change in it, to put back on a rollback.
s.notify_text_tool("batch.begin");
let r = run_steps(s, &label, artboard, &mut cmds, &mut grouped);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => {
s.notify_text_tool("batch.end");
s.batch_steps = Some(grouped);
results
}
Err(e) => {
roll_back(s, start, &stash);
s.notify_text_tool("batch.rollBack");
s.active = active;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved;
s.pending_paint = None;
+3
View File
@@ -122,6 +122,9 @@ impl Session {
if self.depth == 0 {
self.journal_in_undo_groups(doc, &grouped, journal);
self.after_command(Some(id));
} else if matches!(id, "edit.undo" | "edit.redo") {
// A step of `command.batch`: the tool hears of it as it does of the command alone.
self.notify_text_tool(id);
}
Ok(r)
}
+26
View File
@@ -52,3 +52,29 @@ fn undo_and_redo_put_the_caret_where_the_step_changed_the_text() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (6, 6)));
}
/// Undo and Redo run as steps of `command.batch` put the caret where they do run alone.
#[test]
fn undo_and_redo_in_a_batch_put_the_caret_where_the_step_changed_the_text() {
let v = ViewInfo::default();
let mut s = session();
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "XYZ", "size": 20})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, 101.0, 95.0), v).unwrap();
}
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
s.tool_text("abc", v).unwrap();
s.execute("command.batch", &json!({"commands": [{"command": "edit.undo"}]})).unwrap();
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("XYZ", (0, 0)));
s.execute("command.batch", &json!({"commands": [{"command": "edit.redo"}]})).unwrap();
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3)));
// A batch whose later step fails is rolled back: the text and the caret are as they were, and
// Undo still takes back the typing.
let failed = json!({"commands": [{"command": "edit.undo"}, {"command": "no.suchCommand"}]});
assert!(s.execute("command.batch", &failed).is_err());
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("XYZ", (0, 0)));
}
+12 -1
View File
@@ -685,7 +685,7 @@ impl Session {
return;
}
if let Some(cmd @ ("edit.undo" | "edit.redo")) = cmd {
self.with_tool_cx(self.last_view, |t, cx| t.notify(cx, cmd));
self.notify_text_tool(cmd);
}
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.
@@ -694,6 +694,17 @@ impl Session {
}
}
/// Tell the active tool that `edit.undo` or `edit.redo` ran, on its own or as a step of
/// `command.batch`, so the Type tool puts the caret where the step changed the text; or that a
/// `command.batch` began, ended or was rolled back (`batch.begin`, `batch.end`,
/// `batch.rollBack`), so the Type tool puts back what its Undo and Redo steps changed when it fails.
pub(crate) fn notify_text_tool(&mut self, what: &str) {
if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") {
return;
}
self.with_tool_cx(self.last_view, |t, cx| t.notify(cx, what));
}
/// 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);
+44
View File
@@ -92,6 +92,7 @@ struct TypedList {
/// typing session or a typed list), `ends` holds the byte where the changed span ends before and
/// after it, so that Undo and Redo of the step put the caret there even where the change repeats
/// the characters next to it; a step a command from outside the tool made has none.
#[derive(Clone)]
struct OwnEdit {
id: NodeId,
before: u64,
@@ -101,6 +102,18 @@ struct OwnEdit {
done: bool,
}
/// What Undo and Redo change in the tool, kept when a `command.batch` begins so that a batch that
/// fails and is rolled back puts it back ([`TypeTool::batch_notice`]).
struct HistoryState {
editing: Option<NodeId>,
caret: usize,
anchor: usize,
goal_x: Option<f64>,
known: Option<(NodeId, String)>,
edits: Vec<OwnEdit>,
stepped: bool,
}
/// How many undo steps [`TypeTool::edits`] keeps.
const OWN_EDITS_MAX: usize = 256;
@@ -183,6 +196,8 @@ pub struct TypeTool {
edits: Vec<OwnEdit>,
/// The command that ran was Undo or Redo ([`TypeTool::after_history_step`]).
stepped: bool,
/// The state at the start of each `command.batch` still running, innermost last.
batches: Vec<HistoryState>,
cache: Mutex<Option<CachedLayout>>,
}
@@ -515,6 +530,35 @@ impl TypeTool {
self.known = Some((id, text));
}
/// A `command.batch` began (`batch.begin`), ended (`batch.end`), or failed and was rolled back
/// (`batch.rollBack`). After a rollback, what the batch's Undo and Redo steps changed in the tool
/// is as it was at the batch's start, when the tool still edits the same object.
fn batch_notice(&mut self, what: &str) {
match what {
"batch.begin" => self.batches.push(HistoryState {
editing: self.editing,
caret: self.caret,
anchor: self.anchor,
goal_x: self.goal_x,
known: self.known.clone(),
edits: self.edits.clone(),
stepped: self.stepped,
}),
"batch.end" => {
self.batches.pop();
}
"batch.rollBack" => {
if let Some(s) = self.batches.pop()
&& s.editing == self.editing
{
(self.caret, self.anchor, self.goal_x) = (s.caret, s.anchor, s.goal_x);
(self.known, self.edits, self.stepped) = (s.known, s.edits, s.stepped);
}
}
_ => {}
}
}
/// After Undo of a list that typed characters started (and of what was typed after it), the
/// text is as it was typed: the caret goes on after the characters. After Redo the text is as
/// the list left it: the caret goes back to the paragraph's start.
+4 -1
View File
@@ -468,10 +468,13 @@ impl Tool for TypeTool {
self.finish(cx)
}
/// After `text.create` / `text.createInPath` the engine selects the new object; edit it. After
/// `edit.undo` / `edit.redo` the caret goes to where the step changed the edited text.
/// `edit.undo` / `edit.redo` the caret goes to where the step changed the edited text. The
/// `batch.*` notices keep what a failed `command.batch` changed from staying ([`TypeTool::batch_notice`]).
fn notify(&mut self, cx: &ToolContext, what: &str) {
if matches!(what, "edit.undo" | "edit.redo") {
self.after_history_step(cx, what == "edit.undo");
} else if what.starts_with("batch.") {
self.batch_notice(what);
} else if what == "text.editNew"
&& let Some(id) = cx.selection.objects.first().copied()
{