Merge pull request #5985 from dotCooCoo/fix/text-story-current-thread

Type: story-wide panel changes follow the story's current thread
This commit is contained in:
BFlat
2026-11-07 10:32:10 +08:00
committed by GitHub
7 changed files with 342 additions and 22 deletions
+81 -10
View File
@@ -9,7 +9,8 @@
//! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`,
//! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading`
//! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`,
//! with `__initial` (the values it opened with) and `__targets` (the type and range it acts on).
//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on) and,
//! after Select All in threaded text, `__story` (the edited frame, whose story it acts on).
//!
//! Behaviour reference (public documentation):
//! <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>.
@@ -29,6 +30,9 @@ pub const KIND: &str = "justification";
const INITIAL: &str = "__initial";
/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on.
const TARGETS: &str = "__targets";
/// The field holding the frame the Type tool edits when the dialog opened after Select All in
/// threaded text: the dialog then acts on that frame's story as the document holds it.
const STORY: &str = "__story";
const CMD: &str = "text.setFormat";
pub(super) const SPEC: DialogSpec = DialogSpec {
@@ -85,13 +89,12 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
crate::panels::character::text_style(app).ok_or("select some type first")?;
// While typing, the dialog acts on the paragraphs of the text being edited.
let mut targets = json!({});
let mut story = None;
if let Some((id, a, b)) = crate::panels::character::text_editing(app) {
crate::panels::character::end_typing(app);
targets = match crate::panels::character::text_story(app) {
// After Select All in threaded text: every paragraph of every frame.
Some(story) => json!({"ids": story.iter().map(|f| f.0).collect::<Vec<_>>()}),
None => json!({"ids": [id.0], "start": a, "end": b}),
};
targets = json!({"ids": [id.0], "start": a, "end": b});
// After Select All in threaded text: every paragraph of every frame ([`params`]).
story = crate::panels::character::text_story(app).map(|_| id.0);
}
let mut values = Map::new();
for (_, prefix, _) in ROWS {
@@ -115,6 +118,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = Value::Object(values.clone());
fields[INITIAL] = Value::Object(values);
fields[TARGETS] = targets;
if let Some(id) = story {
fields[STORY] = json!(id);
}
fields["preview"] = json!(false);
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
@@ -179,17 +185,30 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(6.0);
ui.colored_label(crate::theme::Tokens::get(ui.ctx()).error, why);
}
None => form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(params(d))),
None => {
let p = params(app, d);
form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(p));
}
}
false
}
/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with,
/// on the dialog's type and range.
fn params(d: &Dialog) -> Map<String, Value> {
/// on the dialog's type and range. After Select All in threaded text the type is every frame of
/// the edited frame's thread as the document holds it now; when that frame is no longer threaded,
/// every paragraph in that frame.
fn params(app: &VectorcraftApp, d: &Dialog) -> Map<String, Value> {
let initial = d.fields.get(INITIAL).and_then(Value::as_object);
let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_f64);
let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default();
if let (Some(id), Some(st)) = (d.fields.get(STORY).and_then(Value::as_u64), app.session.active()) {
let story = st.doc.story_of(vectorcraft_doc::NodeId(id));
p.remove("start");
p.remove("end");
if story.len() >= 2 {
p.insert("ids".into(), json!(story.iter().map(|f| f.0).collect::<Vec<_>>()));
}
}
for (param, prefix, _) in ROWS {
let mut changed = Map::new();
for (suffix, name) in COLUMNS {
@@ -229,7 +248,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if let Some(why) = problem(d) {
return Err(why.into());
}
let p = params(d);
let p = params(app, d);
if !changes(&p) {
if app.session.in_interaction() {
let _ = app.session.cancel_interaction();
@@ -352,6 +371,58 @@ mod tests {
assert_eq!(paras(&app, id).0.justification.auto_leading, 120.0);
}
/// After Select All in threaded text, OK changes the frames in the edited frame's thread when OK
/// is pressed: not a frame that left the thread while the dialog was open, and a frame that
/// joined it.
#[test]
fn justification_dialog_follows_the_story_thread_at_ok() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 900, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame.\n".repeat(6);
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0}));
app.run("select.all", json!({})).unwrap();
app.run("ui.justification", json!({})).unwrap();
app.run("text.thread.break", json!({"id": b, "port": "out"})).unwrap();
let d = app.run("text.thread.add", json!({"from": a, "rect": [600, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
set(&mut app, "autoLeading", json!(140));
super::super::confirm(&mut app).unwrap();
for id in [a, d, b] {
assert_eq!(paras(&app, id).0.justification.auto_leading, 140.0, "frame {id} takes the change");
}
assert_eq!(paras(&app, c).0.justification.auto_leading, 120.0, "the frame that left keeps its paragraphs");
}
/// After Select All in threaded text, a thread broken after the edited frame while the dialog is
/// open leaves the whole story in that frame, and OK changes every paragraph it now holds.
#[test]
fn justification_dialog_takes_the_story_into_a_frame_that_left_its_thread() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 900, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame.\n".repeat(6);
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 400]})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0}));
app.run("select.all", json!({})).unwrap();
app.run("ui.justification", json!({})).unwrap();
app.run("text.thread.break", json!({"id": a, "port": "out"})).unwrap();
set(&mut app, "autoLeading", json!(140));
super::super::confirm(&mut app).unwrap();
let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(NodeId(a)).unwrap().kind else { panic!("not text") };
assert!(t.paragraph_count() >= 6, "the story flows into the frame: {} paragraphs", t.paragraph_count());
for i in 0..t.paragraph_count() {
assert_eq!(t.para_at(i).justification.auto_leading, 140.0, "paragraph {i}");
}
}
/// When paragraphs in scope disagree, only shared justification values are filled in.
#[test]
fn justification_dialog_mixed_paragraph_fields_show_blank() {
+7 -9
View File
@@ -499,18 +499,16 @@ pub(crate) fn text_editing(app: &VectorcraftApp) -> Option<(NodeId, usize, usize
}
/// The frames of the threaded story whose whole text the Type tool has selected after Select All,
/// in thread order; `None` when its selection is in one type object.
/// in the order of the edited frame's thread as the document holds it now; `None` when its
/// selection is in one type object.
pub(crate) fn text_story(app: &VectorcraftApp) -> Option<Vec<NodeId>> {
let o = app.session.tool_options();
let st = app.session.active()?;
let ids: Vec<NodeId> = o
.get("story")?
.as_array()?
.iter()
.filter_map(Value::as_u64)
.map(NodeId)
.filter(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))))
.collect();
let id = NodeId(o.get("editing")?.as_u64()?);
if o.get("story")?.as_array()?.is_empty() {
return None;
}
let ids = st.doc.story_of(id);
(ids.len() >= 2).then_some(ids)
}
@@ -1329,3 +1329,105 @@ fn select_all_in_threaded_text_formats_and_copies_the_whole_story() {
.collect();
assert_eq!(copied, [story]);
}
/// A story threaded through three frames in order, the first `height` tall.
fn three_frame_story(height: u32) -> (VectorcraftApp, u64, u64, u64) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 900, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(6);
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": height}});
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap();
(app, a, b, c)
}
/// Select All with the Type tool in frame `id` of a story.
fn select_all_from(app: &mut VectorcraftApp, id: u64) {
app.run("select.set", json!({"ids": [id]})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
app.session.set_tool_option("textSelection", &json!({"editing": id, "anchor": 0, "caret": 0}));
app.run("select.all", json!({})).unwrap();
}
fn story_frame(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject {
match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn story_sizes(app: &VectorcraftApp, id: u64) -> Vec<f64> {
story_frame(app, id).runs.iter().map(|r| r.style.size).collect()
}
/// The frames that Select All saved are not the frames of the story once the first of them has
/// left the thread: the edited frame's story takes the change.
#[test]
fn story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves() {
// The first frame is too short for a line, so Select All edits the second.
let (mut app, a, b, c) = three_frame_story(1);
select_all_from(&mut app, c);
assert_eq!(app.session.tool_options()["story"], json!([a, b, c]));
assert_eq!(text_editing(&app).unwrap().0, NodeId(b));
app.run("text.thread.break", json!({"id": a, "port": "out"})).unwrap();
assert_eq!(text_editing(&app).unwrap().0, NodeId(b));
assert_eq!(text_story(&app), Some(vec![NodeId(b), NodeId(c)]));
format(&mut app, json!({"size": 9}));
assert!(story_sizes(&app, a).iter().all(|s| (*s - 12.0).abs() < 1e-9), "the frame that left keeps its size");
for id in [b, c] {
assert!(story_sizes(&app, id).iter().all(|s| (*s - 9.0).abs() < 1e-9), "frame {id} takes the size");
}
}
/// A frame that left the thread after Select All takes no change, and a frame added to it does.
#[test]
fn story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined() {
let (mut app, a, b, c) = three_frame_story(60);
select_all_from(&mut app, c);
assert_eq!(text_editing(&app).unwrap().0, NodeId(a));
app.run("text.thread.break", json!({"id": b, "port": "out"})).unwrap();
assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(b)]));
let d = app.run("text.thread.add", json!({"from": a, "rect": [600, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(d), NodeId(b)]));
crate::panels::paragraph::run_on_paragraphs(&mut app, "text.setFormat", json!({"spaceBefore": 4})).unwrap();
for id in [a, d, b] {
assert!((story_frame(&app, id).para_at(0).space_before - 4.0).abs() < 1e-9, "frame {id} takes the space");
}
assert!(story_frame(&app, c).para_at(0).space_before.abs() < 1e-9, "the frame that left keeps its paragraphs");
}
/// With the thread gone, the selection is in the one edited frame.
#[test]
fn story_changes_stay_in_the_edited_frame_once_its_thread_is_removed() {
let (mut app, a, b, c) = three_frame_story(60);
select_all_from(&mut app, c);
assert_eq!(text_editing(&app).unwrap().0, NodeId(a));
app.run("text.thread.remove", json!({})).unwrap();
assert_eq!(text_story(&app), None);
crate::panels::paragraph::run_on_paragraphs(&mut app, "text.setFormat", json!({"spaceBefore": 4})).unwrap();
assert!((story_frame(&app, a).para_at(0).space_before - 4.0).abs() < 1e-9);
for id in [b, c] {
assert!(story_frame(&app, id).para_at(0).space_before.abs() < 1e-9, "frame {id} keeps its paragraphs");
}
}
/// A selected story is one text: its locked and hidden frames take the change too.
#[test]
fn story_changes_take_in_locked_and_hidden_frames() {
let (mut app, a, b, c) = three_frame_story(60);
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
select_all_from(&mut app, a);
assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(b), NodeId(c)]));
format(&mut app, json!({"size": 9}));
for id in [a, b, c] {
assert_eq!(story_sizes(&app, id).iter().filter(|s| (**s - 9.0).abs() > 1e-9).count(), 0, "frame {id} takes the size");
}
}
+14 -3
View File
@@ -72,11 +72,18 @@ fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &m
}));
}
/// Paragraphs touched by the Type selection, or all paragraphs of selected text objects.
/// Paragraphs touched by the Type selection (every paragraph of every frame after Select All in
/// threaded text), or all paragraphs of selected text objects.
fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> {
let mut targets = Vec::new();
if let Some((_, a, b)) = super::character::text_editing(app) {
if let NodeKind::Text(text) = &node.kind {
if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) {
for frame in story.iter().filter_map(|id| st.doc.node(*id)) {
if let NodeKind::Text(text) = &frame.kind {
append_indent_targets(frame, 0..text.paragraph_count(), &mut targets);
}
}
} else if let NodeKind::Text(text) = &node.kind {
append_indent_targets(node, text.paragraphs_in(a, b), &mut targets);
}
} else if let Some(st) = app.session.active() {
@@ -174,7 +181,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma
}
}
/// Apply an absolute ruler position through paragraph-format commands.
/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values
/// of their own get one command each, the story's last paragraph first: threaded text re-flows
/// after each command, and a re-flow moves only text after the paragraph that changed.
fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) {
if targets.is_empty() || !target.is_finite() {
return;
@@ -189,6 +198,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para:
super::character::end_typing(app);
let commands: Vec<Value> = targets
.iter()
.rev()
.map(|p| {
json!({"command": "text.setFormat", "params": {
"ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left
@@ -215,6 +225,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para:
super::character::end_typing(app);
let commands: Vec<Value> = targets
.iter()
.rev()
.map(|p| {
json!({"command": "text.setFormat", "params": {
"ids": [p.id], "start": p.start, "end": p.start,
+134
View File
@@ -404,6 +404,140 @@ fn lower_marker_keeps_each_first_line_position_in_one_undo_step() {
assert_eq!(indent_values(&app, 1), (40.0, 28.0));
}
/// After Select All in threaded text, a marker change keeps the left indent and first-line position
/// of each paragraph in every frame of the story, when the edited frame's paragraphs agree and
/// another frame's differ.
#[test]
fn indent_markers_after_select_all_keep_each_story_paragraphs_indents() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
let story = (1..=12).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 200, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
app.run("text.setFormat", json!({"ids": [a], "leftIndent": 24, "firstLineIndent": 12})).unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 250]})).unwrap()["id"].as_u64().unwrap();
app.run("text.setFormat", json!({"ids": [b], "leftIndent": 60, "firstLineIndent": -10})).unwrap();
let paragraphs = |app: &VectorcraftApp| -> Vec<(u64, f64, f64)> {
let st = app.session.active().unwrap();
[a, b]
.into_iter()
.flat_map(|id| match &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
NodeKind::Text(t) => {
(0..t.paragraph_count()).map(|i| (id, t.para_at(i).left_indent, t.para_at(i).first_line_indent)).collect::<Vec<_>>()
}
_ => panic!("not text"),
})
.collect()
};
assert!(paragraphs(&app).iter().any(|p| p.1 == 24.0) && paragraphs(&app).iter().any(|p| p.1 == 60.0), "{:?}", paragraphs(&app));
let select_all = |app: &mut VectorcraftApp| {
app.run("select.set", json!({"ids": [a]})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0}));
app.run("select.all", json!({})).unwrap();
assert_eq!(super::super::character::text_story(app), Some(vec![vectorcraft_doc::NodeId(a), vectorcraft_doc::NodeId(b)]));
};
select_all(&mut app);
let before = paragraphs(&app);
let (node, para, span) = current_para(&app).unwrap();
assert_eq!(node.id.0, a);
let targets = indent_targets(&app, &node);
set_marker(&mut app, IndentMarker::Upper, 70.0, &para, span, &targets, false);
let after = paragraphs(&app);
assert_eq!(after.len(), before.len());
for (was, now) in before.iter().zip(&after) {
assert_eq!((now.1, now.1 + now.2), (was.1, 70.0), "a paragraph of frame {} keeps its left indent: {after:?}", now.0);
}
app.run("edit.undo", json!({})).unwrap();
assert_eq!(paragraphs(&app), before);
select_all(&mut app);
let (node, para, span) = current_para(&app).unwrap();
let targets = indent_targets(&app, &node);
set_marker(&mut app, IndentMarker::Lower, 40.0, &para, span, &targets, false);
let after = paragraphs(&app);
assert_eq!(after.len(), before.len());
for (was, now) in before.iter().zip(&after) {
assert_eq!((now.1, now.1 + now.2), (40.0, was.1 + was.2), "a paragraph of frame {} keeps its first-line position: {after:?}", now.0);
}
}
/// After Select All in threaded text with mixed indents, a marker change that makes the first
/// frame's paragraphs wrap onto more lines pushes text into the next frame while the change is
/// applied. Every paragraph of the story still gets the new position: the upper marker keeps each
/// left indent, and the lower marker keeps each first-line position.
#[test]
fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 1000})).unwrap();
let story = (1..=12).map(|i| format!("p{i} aaa bbb ccc")).collect::<Vec<_>>().join("\n");
let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 200, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 950]})).unwrap()["id"].as_u64().unwrap();
app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap();
// Each paragraph of the story by its first word, with its left indent and first-line position;
// a paragraph continued from the frame before is counted once. No paragraph of the story is
// empty, so an empty piece (after a frame's last line break) is not one.
let paragraphs = |app: &VectorcraftApp| -> Vec<(String, f64, f64)> {
let st = app.session.active().unwrap();
[a, b]
.into_iter()
.flat_map(|id| match &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t
.plain_text()
.split('\n')
.enumerate()
.filter(|(i, text)| !text.is_empty() && (*i > 0 || !t.first_para_continues))
.map(|(i, text)| {
let p = t.para_at(i);
(text.split(' ').next().unwrap_or_default().to_string(), p.left_indent, p.left_indent + p.first_line_indent)
})
.collect::<Vec<_>>(),
_ => panic!("not text"),
})
.collect()
};
let in_a = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().kind {
NodeKind::Text(t) => t.plain_text().len(),
_ => panic!("not text"),
};
// Select All in the first frame and move `marker` to `to` → the story's paragraphs before and
// after.
let apply = |app: &mut VectorcraftApp, marker, to| {
let before = paragraphs(app);
assert_eq!(before.len(), 12, "{before:?}");
let shown = in_a(app);
app.run("select.set", json!({"ids": [a]})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0}));
app.run("select.all", json!({})).unwrap();
let (node, para, span) = current_para(app).unwrap();
let targets = indent_targets(app, &node);
set_marker(app, marker, to, &para, span, &targets, false);
let after = paragraphs(app);
assert!(in_a(app) < shown, "the first frame holds less text once it wraps: {} of {shown} bytes, {after:?}", in_a(app));
assert_eq!(after.iter().map(|p| &p.0).collect::<Vec<_>>(), before.iter().map(|p| &p.0).collect::<Vec<_>>());
(before, after)
};
let (before, after) = apply(&mut app, IndentMarker::Upper, 150.0);
assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}");
for (was, now) in before.iter().zip(&after) {
assert_eq!((now.1, now.2), (was.1, 150.0), "{} keeps its left indent and starts at 150 pt: {after:?}", now.0);
}
app.run("edit.undo", json!({})).unwrap();
assert_eq!(paragraphs(&app), before);
// First lines at 150 and 160 pt; a left indent of 180 pt leaves room for one word a line.
app.run("text.setFormat", json!({"ids": [a, b], "firstLineIndent": 150})).unwrap();
let (before, after) = apply(&mut app, IndentMarker::Lower, 180.0);
assert!(before.iter().any(|p| p.2 == 150.0) && before.iter().any(|p| p.2 == 160.0), "{before:?}");
for (was, now) in before.iter().zip(&after) {
assert_eq!((now.1, now.2), (180.0, was.2), "{} is indented 180 pt and keeps its first-line position: {after:?}", now.0);
}
}
#[test]
fn lower_marker_bounds_cover_each_touched_first_line() {
let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }];
+2
View File
@@ -5071,6 +5071,8 @@ and Cut take. Typing, Delete and Cut replace the whole story as one undo step (`
frames whose text goes), and a fill, stroke, `type.step` or Character or Paragraph panel change applies to the text of
every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, which styles every frame's text in one
undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as
one undo step. The panels change the frames that are in the edited frame's thread at the time of the change, locked and
hidden frames included, and not a frame that has left it. A caret move or a click ends the story selection.
one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected,
and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of
every frame as one undo step, and `appearance.copyFrom` leaves out a frame that is its `source`. While the story stays
+2
View File
@@ -739,6 +739,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`).
- With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`).
- With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`).
- With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`).
- Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`).
- While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command.
- On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut.