diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 62a8859f1..880b2838d 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Convert to Text", [], None, - "{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches; after Select All in a frame of threaded text, without `ids`, `id` or a range, every paragraph of the story's frames that are neither locked nor hidden; while the story is selected, its locked and hidden frames are not converted even when named) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them (a whole story that Select All selected stays selected when the call converts all of it); in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}", + "{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches; after Select All in a frame of threaded text, without `ids`, `id` or a range, every paragraph of the story's frames that are neither locked nor hidden; while the story is selected, its locked and hidden frames are not converted even when named, nor a paragraph whose text starts in one) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them (a whole story that Select All selected stays selected when the call converts all of it); in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}", has_doc, convert_list_to_text ), @@ -888,6 +888,9 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { } else { super::typecmd::text_targets(s, p, C)?.into_iter().filter(|id| !full.contains(id) || story.contains(id)).collect() }; + // The selected story's locked and hidden frames: no paragraph whose text starts in one is + // converted, and none of their paragraphs is restyled. + let held: Vec = full.iter().copied().filter(|f| !story.contains(f)).collect(); // Converting the whole story leaves it selected. let whole_story = !story.is_empty() && range.is_none() && story.iter().all(|f| ids.contains(f)); let editing = match super::textedit::editing_range(s)? { @@ -921,6 +924,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { let mut converted = 0; for story in &stories { let frames: Vec<&vectorcraft_doc::TextObject> = story.iter().filter_map(|id| text(*id)).collect(); + let is_held = |f: usize| story.get(f).is_some_and(|id| held.contains(id)); let mut paras: Vec = vec![]; let mut named_parts = vec![]; let plains: Vec = frames.iter().map(|t| t.plain_text()).collect(); @@ -946,7 +950,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { } for (i, named) in named_parts { if let Some(para) = paras.get_mut(i) { - let listed = para.main_part().is_some_and(|p| p.list.is_some()); + let listed = para.main_part().is_some_and(|p| p.list.is_some() && !is_held(p.frame)); para.converts |= named && listed; } } @@ -1033,7 +1037,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { let tab = vectorcraft_doc::TabStop { position: x, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' }; style.tabs.insert(i, tab); } - for part in story_para.parts.iter().filter(|q| std::ptr::eq(*q, para) || q.bytes.is_empty()) { + for part in story_para.parts.iter().filter(|q| (std::ptr::eq(*q, para) || q.bytes.is_empty()) && !is_held(q.frame)) { if let Some((stops, _)) = frame_edits.get_mut(part.frame) { stops.push((part.index, style.clone())); } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 31962ab96..3b90d0dc2 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1733,10 +1733,20 @@ fn convert_to_text_of_the_whole_story_leaves_a_locked_frame() { s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); s.set_tool_option("textSelection", &json!({"editing": a.0, "anchor": 0, "caret": 0})); assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); + // The first frame ends with a paragraph return, so the paragraph that starts the locked + // frame takes in the first frame's empty last part and starts its text there. + let in_a = obj(&s, a).plain_text(); + assert!(in_a.ends_with('\n')); + let own = in_a.split('\n').filter(|piece| !piece.is_empty()).count(); let p = if named { json!({"ids": [a.0, b.0]}) } else { json!({}) }; - s.execute("text.convertListToText", &p).unwrap(); + let r = s.execute("text.convertListToText", &p).unwrap(); + assert_eq!(r["ids"], json!([a.0]), "only the editable frame is changed (ids named: {named})"); + assert_eq!(r["converted"], json!(own), "only the paragraphs whose text starts in the editable frame convert"); + assert!(r["inserted"].as_array().unwrap().iter().all(|i| i["id"] == json!(a.0))); let (ta, tb) = (obj(&s, a), obj(&s, b)); assert!(ta.plain_text().contains("\u{2022}\t"), "the editable frame is converted"); + let markers = ta.plain_text().matches("\u{2022}\t").count() + tb.plain_text().matches("\u{2022}\t").count(); + assert_eq!(markers, own, "the locked frame's first paragraph keeps its list"); // The converted text is longer, so the story flows on into the locked frame; its own // paragraphs stay a list. let last = tb.paragraph_count() - 2; @@ -1746,6 +1756,41 @@ fn convert_to_text_of_the_whole_story_leaves_a_locked_frame() { } } +#[test] +fn convert_to_text_of_the_whole_story_leaves_a_paragraph_that_starts_in_a_locked_frame() { + for named in [false, true] { + let mut s = session(); + let story = format!("Alpha beta.\nAlpha beta.\n{}end.\nAlpha beta.\nAlpha beta.\n", "Gamma delta ".repeat(40)); + let a = NodeId( + s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 900})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a.0, b.0], "list": "bullet"})).unwrap(); + let in_a = obj(&s, a).plain_text(); + assert!(!in_a.ends_with('\n') && !obj(&s, b).plain_text().starts_with('\n'), "a paragraph runs on from the first frame into the second"); + s.execute("object.lock", &json!({"ids": [a.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0})); + assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); + let p = if named { json!({"ids": [a.0, b.0]}) } else { json!({}) }; + let r = s.execute("text.convertListToText", &p).unwrap(); + assert_eq!(r["ids"], json!([b.0]), "only the editable frame is changed (ids named: {named})"); + // The two paragraphs that start in the editable frame, and the empty one that ends the story. + assert_eq!(r["converted"], json!(3), "only the paragraphs that start in the editable frame convert"); + let (ta, tb) = (obj(&s, a), obj(&s, b)); + assert_eq!(ta.plain_text(), in_a, "the locked frame's text is as it was"); + assert!((0..ta.paragraph_count()).all(|k| ta.para_at(k).list.is_some()), "the locked frame stays a list"); + assert!(tb.para_at(0).list.is_some(), "the paragraph the locked frame starts stays a list"); + assert!(!tb.plain_text().starts_with('\u{2022}')); + assert_eq!(tb.plain_text().matches("\u{2022}\t").count(), 2); + } +} + #[test] fn convert_to_text_of_named_frames_ends_the_story_selection() { let (mut s, _, b, _) = threaded_list_edited_in_its_second_frame(); diff --git a/docs/mcp.md b/docs/mcp.md index 594da6640..5fdb5c53a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5432,8 +5432,8 @@ Type tool edits, `text.convertListToText` ends its typing first and moves its an inserted at or before them, whether the call comes from the menu, MCP or the control channel. After Select All in a frame of threaded text, a call without `ids`, `id`, `start` or `end` converts every paragraph of the story's frames that are neither locked nor hidden. While the story is selected, its locked and hidden frames are -not converted, even when `ids` names them. When the call converts all of its other frames, the whole story stays -selected. +not converted, even when `ids` names them, and neither is a paragraph whose text starts in one. When the call converts +all of its other frames, the whole story stays selected. In threaded type, when the caret's character moved to another frame, the Type tool edits that frame with the caret next to the same character, and that frame becomes the selection when it was not selected.