Merge pull request #5897 from dotCooCoo/fix/restyled-empty-frame-keeps-style

Text: a restyled empty frame keeps its style before an unedited empty frame
This commit is contained in:
BFlat
2026-11-06 16:09:53 +08:00
committed by GitHub
3 changed files with 62 additions and 2 deletions
+21
View File
@@ -39,6 +39,27 @@ fn empty_later_frames_keep_the_first_frames_edited_paragraph_style() {
assert_eq!([hyphenates(a), hyphenates(b), hyphenates(c)], [true, true, true]);
}
#[test]
fn a_restyled_empty_frame_keeps_its_style_before_an_unedited_empty_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "First\n", "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!([text(&s, a), text(&s, b), text(&s, c)], ["First\n", "", ""]);
s.execute("text.setFormat", &json!({"ids": [b], "hyphenate": true})).unwrap();
let hyphenates = |id, para| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.para_at(para).hyphenate,
_ => panic!("not text"),
};
// The empty paragraph after the break takes the style; the paragraph "First" keeps its own.
assert_eq!([hyphenates(a, 0), hyphenates(a, 1), hyphenates(b, 0), hyphenates(c, 0)], [false, true, true, true]);
}
#[test]
fn thread_flows_reflows_on_edit_and_survives_delete() {
let (mut s, a, b) = setup();
+8 -2
View File
@@ -20,24 +20,30 @@ pub fn story(frames: &[&TextObject]) -> Vec<TextRun> {
/// paragraph shares that paragraph with the next frame (its first one): the earlier frame's style
/// wins unless `later_wins[k]` says frame `k`'s first paragraph should (it was just restyled), or
/// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break).
/// An empty frame that was just restyled keeps its style against unedited empty frames after it.
pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec<ParaStyle> {
let mut out: Vec<ParaStyle> = vec![];
let mut prev_empty_tail = true;
let mut empty_tail_restyled = false;
for (k, f) in frames.iter().enumerate() {
let text = f.plain_text();
let restyled = later_wins.get(k).copied().unwrap_or(false);
let mut styles = f.paragraph_styles().into_iter();
if k > 0
&& let Some(first) = styles.next()
&& (prev_empty_tail || later_wins.get(k).copied().unwrap_or(false))
&& (restyled || (prev_empty_tail && !(text.is_empty() && empty_tail_restyled)))
&& let Some(last) = out.last_mut()
{
*last = first;
}
out.extend(styles);
let text = f.plain_text();
// An empty frame has no paragraph boundary of its own. Keep the preceding frame's
// boundary state so another empty frame cannot replace a paragraph's edited style.
if !text.is_empty() {
prev_empty_tail = text.ends_with('\n') || out.is_empty();
empty_tail_restyled = false;
} else if restyled {
empty_tail_restyled = true;
}
}
out
+33
View File
@@ -163,6 +163,39 @@ fn empty_frames_do_not_replace_an_earlier_paragraph_style() {
assert_eq!(story_paras(&[&first, &restyled_middle, &last], &[false, true, false])[0].justify, Justify::Center);
}
/// When the earlier frame ends with a paragraph break, a restyled empty frame keeps its style
/// against an unedited empty frame after it. An unedited empty frame before it, a frame with
/// text after it, or a restyled frame after it still gives the paragraph its own style.
#[test]
fn a_restyled_empty_frame_keeps_its_style_after_a_paragraph_break() {
let style = |j| ParaStyle { justify: j, ..Default::default() };
let mut first = frame(0.0);
first.runs = vec![TextRun { text: "First\n".into(), style: CharStyle::default(), inline: None }];
first.set_all_paras(style(Justify::Right));
let mut restyled = frame(200.0);
restyled.set_all_paras(style(Justify::Center));
let empty = frame(400.0);
let tail_style = |frames: &[&TextObject], wins: &[bool]| story_paras(frames, wins).last().map(|p| p.justify);
assert_eq!(tail_style(&[&first, &restyled, &empty], &[false, true, false]), Some(Justify::Center));
assert_eq!(tail_style(&[&first, &restyled, &empty, &empty], &[false, true, false, false]), Some(Justify::Center));
// An unedited empty frame before the restyled one gives way to it.
assert_eq!(tail_style(&[&first, &empty, &restyled, &empty], &[false, false, true, false]), Some(Justify::Center));
// A restyled first frame of an empty thread keeps its style as well.
assert_eq!(tail_style(&[&restyled, &empty, &empty], &[true, false, false]), Some(Justify::Center));
// Without an edit, the last empty frame still gives the empty paragraph its style.
assert_eq!(tail_style(&[&first, &restyled, &empty], &[false, false, false]), Some(Justify::Left));
// A later frame with text still gives the paragraph it holds its style.
let mut with_text = empty.clone();
with_text.runs = vec![TextRun { text: "Tail".into(), style: CharStyle::default(), inline: None }];
assert_eq!(tail_style(&[&first, &restyled, &with_text], &[false, true, false]), Some(Justify::Left));
// A later restyled frame wins over the earlier one.
let mut right = empty.clone();
right.set_all_paras(style(Justify::Right));
assert_eq!(tail_style(&[&first, &restyled, &right], &[false, true, true]), Some(Justify::Right));
}
/// Numbered list paragraphs count on from one frame to the next: the second frame's first
/// whole paragraph takes the next number. A paragraph split between the frames has its number
/// in the first frame only, and the paragraph after it takes the next number.