Type: story-wide panel changes follow the story's current thread

After Select All in threaded text, the panels read the frames from the
list saved at Select All. A frame that had left the thread still took
Paragraph, Tabs, Justification and Text Styles changes, a frame added
later did not, and when the first saved frame had left, Character,
OpenType and font changes went to that frame alone.

text_story now reads the frames of the edited frame's thread from the
document. Locked and hidden frames stay in the story.
This commit is contained in:
dotCooCoo
2026-11-07 09:17:46 +07:00
committed by bflatastic
parent 845306373b
commit 40a46e539d
4 changed files with 112 additions and 9 deletions
+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");
}
}