diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b3fab4a2bc..26d6a02a94 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec { "Set Text", [], None, - "{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", + "{id?|ids?, text} replace the contents of text objects (keeps the first run's style); no undo step when no object changes (each already has the text in one style, every paragraph with the first one's attributes, and no ruby) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", has_doc, set_text ), @@ -104,7 +104,7 @@ pub fn specs() -> Vec { "Reshape Type Area", [], None, - "{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step", + "{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step; no undo step when no frame changes (dx and dy both 0)", has_doc, reshape_area ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec { "Reshape Type Path", [], None, - "{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)", + "{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path); no undo step when the path does not change (dx and dy both 0)", has_doc, reshape_path ), @@ -122,7 +122,7 @@ pub fn specs() -> Vec { "Move Type Path Handle", [], None, - "{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)", + "{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path); no undo step when the handle is already there and the path does not change", has_doc, set_path_handle ), @@ -131,7 +131,7 @@ pub fn specs() -> Vec { "Fit Headline", ["Type"], None, - "{ids?|id?} track the first line of area type so it fills the frame width → {tracking}", + "{ids?|id?} track the first line of area type so it fills the frame width; no undo step when the first line already has that tracking → {tracking}", has_selection, fit_headline ), @@ -140,7 +140,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), @@ -149,7 +149,7 @@ pub fn specs() -> Vec { "Ruby…", [], None, - "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", + "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead; no undo step when the ruby does not change (the same reading and options again, or `remove` where there is none) → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", has_selection, ruby ), @@ -244,8 +244,8 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result { } /// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one -/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby -/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). +/// undo step (none when nothing changes). Without `start`/`end` the range is all the text; a new +/// reading replaces the ruby over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). fn ruby(s: &mut Session, p: &Value) -> Result { const C: &str = "type.ruby"; let ids = text_targets(s, p, C)?; @@ -272,7 +272,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result { } let overhang = switch_or(p, "overhang", true, C)?; let mut first: Option = None; - s.edit("Ruby", |d, _| { + // The same reading again, or a removal where there is no ruby, is no undo step. + edit_if_nodes_changed(s, "Ruby", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); @@ -299,7 +300,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result { first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null)); } } - Ok(()) + Ok(((), false)) })?; Ok(json!({"ids": ids.iter().map(|id| id.0).collect::>(), "ruby": first.unwrap_or(Value::Null)})) } @@ -734,6 +735,19 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// [`Session::edit`] for a type command that may set what the type already has: `f` runs on a +/// copy of the document, and it becomes an undo step only when one of the objects `ids` differs +/// afterwards or `f` returns true for a change of its own elsewhere (a graph's kept label styles). +/// When nothing changed, the document, its undo and redo steps and its saved state stay as they +/// were. Only the objects are compared, not the whole document, whose embedded images can be large. +fn edit_if_nodes_changed(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result { + super::colorcmds::edit_if_changed(s, label, |before, d| { + let (v, other) = f(d)?; + let changed = other || ids.iter().any(|id| before.node(*id) != d.node(*id)); + Ok((v, changed)) + }) +} + pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let ids = targets(s, p)?; let t = text_ids(&s.doc()?.doc, &ids); @@ -828,7 +842,8 @@ pub(crate) fn style_chars(t: &mut TextObject, range: Option, mut f: i fn set_text(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string(); let ids = text_targets(s, p, "text.setText")?; - s.edit("Typing", |d, _| { + // The text the type already has is no undo step. + edit_if_nodes_changed(s, "Typing", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let style = t.first_style(); @@ -839,7 +854,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result { t.ruby.clear(); refresh_bounds(t); } - Ok(()) + Ok(((), false)) })?; Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))) } @@ -912,7 +927,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result { let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some()); // The fonts the styled characters are set in, for the warning. let mut set = std::collections::BTreeSet::new(); - s.edit("Character", |d, _| { + // Attributes the characters already have are no undo step. + edit_if_nodes_changed(s, "Character", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?; @@ -987,8 +1003,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result { } // Graph labels keep a change made to all of their text through the graph being drawn again (sizes as given, // not in a scaled label's own units). + let mut labels_changed = false; if range.is_none() { - super::graph::capture_label_styles(d, &ids, &|st| { + labels_changed = super::graph::capture_label_styles(d, &ids, &|st| { if let Some(f) = &font { st.font_family = f.clone(); st.font_version = None; @@ -1031,7 +1048,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { } }); } - Ok(()) + Ok(((), labels_changed)) })?; let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); if let Some(w) = super::fonts::set_font_warning(set) { @@ -1329,14 +1346,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { items.push((id, refs)); } let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); - s.edit("Reshape Type Area", |doc, _| { + // Anchors moved by no distance are no undo step. + let ids: Vec = items.iter().map(|(id, _)| *id).collect(); + edit_if_nodes_changed(s, "Reshape Type Area", &ids, |doc| { for (id, refs) in &items { let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) }; if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) { return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas")); } } - Ok(()) + Ok(((), false)) })?; ok() } @@ -1356,7 +1375,8 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not type on a path outside perspective")); } - s.edit("Reshape Type Path", |doc, _| { + // Anchors moved by no distance are no undo step. + edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| { let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let before = t.kind.clone(); let within = |t: &TextObject| { @@ -1367,7 +1387,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "no such path anchor, or the path would reach past the canvas")); } refresh_bounds(t); - Ok(()) + Ok(((), false)) })?; ok() } @@ -1385,7 +1405,8 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not type on a path outside perspective")); } - s.edit("Reshape Type Path", |doc, _| { + // A handle moved to where it is is no undo step. + edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| { let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let before = t.kind.clone(); let within = |t: &TextObject| { @@ -1396,7 +1417,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "no such path anchor, or the path would reach past the canvas")); } refresh_bounds(t); - Ok(()) + Ok(((), false)) })?; ok() } @@ -1407,6 +1428,8 @@ mod block; pub(crate) use block::needed_room; #[cfg(test)] mod block_tests; +#[cfg(test)] +mod noop_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. @@ -1448,7 +1471,9 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { plans.push((*id, (lo * 10.0).floor() / 10.0)); } let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?; - s.edit("Fit Headline", |d, _| { + // A headline that already has its fitted tracking is no undo step. + let fitted: Vec = plans.iter().map(|(id, _)| *id).collect(); + edit_if_nodes_changed(s, "Fit Headline", &fitted, |d| { for (id, tr) in &plans { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(vectorcraft_text::edit::runs_len(&t.runs)); @@ -1456,7 +1481,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { refresh_bounds(t); } } - Ok(()) + Ok(((), false)) })?; Ok(json!({ "tracking": first })) } diff --git a/crates/engine/src/cmd/typecmd/noop_tests.rs b/crates/engine/src/cmd/typecmd/noop_tests.rs new file mode 100644 index 0000000000..02481d8bee --- /dev/null +++ b/crates/engine/src/cmd/typecmd/noop_tests.rs @@ -0,0 +1,143 @@ +use super::*; + +/// The document and the number of undo steps, to compare before and after a command that sets +/// what the type already has. +fn state(s: &Session) -> (std::sync::Arc, usize) { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len()) +} + +/// Assert that `cmd` with `p` leaves the document and its undo history as they were. +fn records_nothing(s: &mut Session, cmd: &str, p: Value) { + let before = state(s); + s.execute(cmd, &p).unwrap(); + let after = state(s); + assert!(*after.0 == *before.0, "{cmd} {p} changed the document"); + assert_eq!(after.1, before.1, "{cmd} {p} recorded an undo step"); +} + +/// Assert that `cmd` with `p` changes the document in exactly one undo step. +fn records_one_step(s: &mut Session, cmd: &str, p: Value) { + let before = state(s); + s.execute(cmd, &p).unwrap(); + let after = state(s); + assert!(*after.0 != *before.0, "{cmd} {p} left the document as it was"); + assert_eq!(after.1, before.1 + 1, "{cmd} {p} did not record one undo step"); +} + +fn session_with(text: &str, area: Option) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let mut p = json!({"x": 10, "y": 20, "text": text, "size": 24}); + if let Some(a) = area { + p["area"] = a; + } + let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) +} + +/// Ruby set again with the same reading and options, or removed where there is none, records no +/// undo step; a step that does change the ruby still undoes in one. +#[test] +fn ruby_that_changes_nothing_records_no_undo_step() { + let (mut s, id) = session_with("漢字です", None); + records_nothing(&mut s, "type.ruby", json!({"remove": true})); + let p = json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 60}); + records_one_step(&mut s, "type.ruby", p.clone()); + records_nothing(&mut s, "type.ruby", p); + s.execute("type.ruby", &json!({"remove": true})).unwrap(); + records_nothing(&mut s, "type.ruby", json!({"remove": true, "start": 0, "end": 6})); + s.execute("edit.undo", &json!({})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.ruby.len(), 1, "undo brings the ruby back"); +} + +/// `text.setStyle` (the Character panel) given the attributes the characters already have +/// records no undo step, with a range too. +#[test] +fn character_attributes_already_set_record_no_undo_step() { + let (mut s, _) = session_with("Headline", None); + records_nothing(&mut s, "text.setStyle", json!({"size": 24})); + s.execute("text.setStyle", &json!({"tracking": 50, "fill": "#ff0000"})).unwrap(); + records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "fill": "#ff0000"})); + records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "start": 0, "end": 4})); + s.execute("text.setStyle", &json!({"justify": "center"})).unwrap(); + records_nothing(&mut s, "text.setStyle", json!({"justify": "center"})); + records_one_step(&mut s, "text.setStyle", json!({"tracking": 80, "start": 0, "end": 4})); +} + +/// `text.setStyle` on a graph label that leaves the label's own text as it is but changes the +/// style its kind of label keeps still records one undo step; given again, it records none. +#[test] +fn a_kept_graph_label_style_change_alone_records_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4"; + let graph = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let doc = s.doc().unwrap().doc.clone(); + let legend = doc.node(graph).unwrap().children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap(); + let (label, size) = legend + .children() + .unwrap() + .iter() + .find_map(|c| match &c.kind { + NodeKind::Text(t) if t.plain_text() == "2025" => Some((c.id, t.runs[0].style.size)), + _ => None, + }) + .unwrap(); + let kept = |s: &Session| s.doc().unwrap().doc.node(graph).unwrap().extras().graph.as_deref().unwrap().label_styles.legend.clone(); + assert!(kept(&s).is_none()); + let p = json!({"ids": [label.0], "size": size}); + let before = state(&s); + s.execute("text.setStyle", &p).unwrap(); + let after = state(&s); + assert!(after.0.node(label) == before.0.node(label), "the label's own text changed"); + assert!(kept(&s).is_some(), "the legend keeps the style"); + assert_eq!(after.1, before.1 + 1, "the kept style change recorded no undo step"); + records_nothing(&mut s, "text.setStyle", p); +} + +/// `text.setText` with the text the type already has records no undo step; new text records one. +#[test] +fn the_same_text_records_no_undo_step() { + let (mut s, _) = session_with("Same words", None); + records_nothing(&mut s, "text.setText", json!({"text": "Same words"})); + records_one_step(&mut s, "text.setText", json!({"text": "Other words"})); +} + +/// Fit Headline run again on a headline it already fitted records no undo step; the first run +/// records one. +#[test] +fn fit_headline_again_records_no_undo_step() { + let (mut s, _) = session_with("HEADLINE\nbody text", Some(json!({"width": 300, "height": 120}))); + let start = state(&s); + let tracking = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].clone(); + let fitted = state(&s); + assert!(*fitted.0 != *start.0 && fitted.1 == start.1 + 1, "the first Fit Headline did not record one undo step"); + let before = state(&s); + assert_eq!(s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"], tracking); + let after = state(&s); + assert!(*after.0 == *before.0 && after.1 == before.1, "a second Fit Headline recorded an undo step"); +} + +/// Reshaping area type or type on a path by no distance, or moving a path handle to where it is, +/// records no undo step; a reshape that moves something records one. +#[test] +fn reshaping_type_by_nothing_records_no_undo_step() { + let (mut s, id) = session_with("Boxed words", Some(json!({"width": 200, "height": 100}))); + records_nothing(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 0, "dy": 0})); + records_one_step(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 20, "dy": 10})); + let path = + s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap(); + let on_path = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap()["id"].clone(); + records_nothing(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 0})); + records_one_step(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 20})); + let handle = json!({"id": on_path, "anchor": 0, "which": "out", "x": 100, "y": 250}); + records_one_step(&mut s, "text.setPathHandle", handle.clone()); + records_nothing(&mut s, "text.setPathHandle", handle); +} diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..b264c15cca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8253,7 +8253,8 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane text reflows along it. `text.setPathHandle {id, subpath?, anchor, which: "in"|"out", x, y, independent?}` moves a direction handle of one of its anchors, as `path.setHandle` moves a path's (Direct Selection dragging a handle of the path, #3565), in one -undo step. +undo step. A call of `text.reshapeArea`, `text.reshapePath` or `text.setPathHandle` that changes nothing (`dx` and +`dy` both 0, or a handle moved to where it already is) adds no undo step and keeps redo. ```json {"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}} @@ -8318,7 +8319,10 @@ the end of the path), and `fit`, `fitScale` and `dynamicText` (Block is on) for Given for several area types (`ids`, or a selection of several), `text.areaOptions` changes only the options given on each one: each frame keeps its other options (it used to take the first frame's), and `fitMinPercent` alone changes only the frames that already shrink text to fit. Options that change nothing (the vertical alignment already set, say) add -no undo step and keep redo. +no undo step and keep redo. The same holds for `text.setText` when no object changes (the text it already has, in one +style, every paragraph with the first one's attributes and no ruby), `text.setStyle` when no character, paragraph or +graph label style changes, `type.ruby` with the same reading and options again or `remove` where there is no ruby, and +`text.fitHeadline` on a first line that already has the tracking it would set. Text that doesn't fit is drawn up to the last line that does, so a one-line headline in a frame lower than its line draws nothing. The commands that make type or change what it holds say so in their own reply: `text.create`,