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");
}
}
+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
+1
View File
@@ -739,6 +739,7 @@ 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`).
- 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.