Undo: no step when Ruby, Character or another type command changes nothing

type.ruby, text.setStyle, text.setText, text.fitHeadline, text.reshapeArea,
text.reshapePath and text.setPathHandle recorded an undo step even when the
call left the type as it was: the same ruby reading again, a removal where
there is no ruby, attributes the characters already have, the same text, a
second run of Fit Headline, or a reshape by 0. Each now runs on a copy and
records a step only when one of its type objects (or, for text.setStyle, a
graph's kept label styles) changed; otherwise the undo and redo steps and
the saved state stay as they were.

Area Type Options already skips unchanged options, and Scale Text
Attributes returns an error for type with no scale to bake, so neither
changes. Type Orientation, Optical Margin Alignment, System Layout and
Fractional Widths are fixed in #7505.
This commit is contained in:
dotCooCoo
2026-11-14 03:22:36 -08:00
parent edb228ac33
commit 5d3cd69b6a
3 changed files with 198 additions and 26 deletions
+49 -24
View File
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<Value> {
}
/// 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<Value> {
const C: &str = "type.ruby";
let ids = text_targets(s, p, C)?;
@@ -272,7 +272,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
}
let overhang = switch_or(p, "overhang", true, C)?;
let mut first: Option<Value> = 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<Value> {
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::<Vec<_>>(), "ruby": first.unwrap_or(Value::Null)}))
}
@@ -734,6 +735,19 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// [`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<T>(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result<T> {
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<Vec<NodeId>> {
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<TextRange>, mut f: i
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
t.ruby.clear();
refresh_bounds(t);
}
Ok(())
Ok(((), false))
})?;
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
}
@@ -912,7 +927,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
// 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<Value> {
}
});
}
Ok(())
Ok(((), labels_changed))
})?;
let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
if let Some(w) = super::fonts::set_font_warning(set) {
@@ -1329,14 +1346,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<NodeId> = 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<Value> {
refresh_bounds(t);
}
}
Ok(())
Ok(((), false))
})?;
Ok(json!({ "tracking": first }))
}
+143
View File
@@ -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<Document>, 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<Value>) -> (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);
}
+6 -2
View File
@@ -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`,