mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 14:47:05 -06:00
Text: a column, frame or page break ends its paragraph
A break character is stored as the last character of the paragraph it ends, followed by the usual '\n' separator. The story model, paragraph ranges, styles, editing and every exporter keep splitting paragraphs on '\n' only, so the change stays in the places that create, lay out or write break characters: - IDML import writes the break and ends the paragraph, so the range that follows keeps its own paragraph style. NextOddPage and NextEvenPage map to new odd and even page break characters; IDML export writes a paragraph that ends in a break as that one Br with its ParagraphBreakType, so export and re-import give the same story. - Word and RTF import, Type > Insert Break Character (with odd and even page breaks added) and typing end the paragraph after a break. Typing between a break and its return goes to the next paragraph; deleting either deletes both. - Layout: a break that ends a paragraph leaves no empty line; the next paragraph starts in the next column, frame or page, as a paragraph start option would. Page breaks and Next Page start options move to the next frame on a later page; odd and even ones to the next page with that page number parity (no such frame: overset). A break inside a paragraph from an older document still moves the rest of that paragraph on. Storing the break as a paragraph separator of its own would have changed every place that counts or splits paragraphs on '\n'.
This commit is contained in:
@@ -80,6 +80,8 @@ fn metachars(s: &str, grep: bool) -> String {
|
||||
("~M", "\u{E002}"),
|
||||
("~R", "\u{E003}"),
|
||||
("~P", "\u{E004}"),
|
||||
("~L", "\u{E011}"),
|
||||
("~E", "\u{E012}"),
|
||||
("~=", "\u{2014}"),
|
||||
("~_", "\u{2014}"),
|
||||
("~-", "\u{AD}"),
|
||||
|
||||
@@ -370,6 +370,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let r = t.range();
|
||||
let mut r = r.start.min(st.len())..r.end.min(st.len());
|
||||
// Between a break character and its return is the end of that paragraph: typing there goes
|
||||
// to the start of the next one.
|
||||
if r.is_empty()
|
||||
&& st.text.get(r.start..).is_some_and(|t| t.starts_with('\n'))
|
||||
&& st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char)
|
||||
{
|
||||
r = r.start + 1..r.start + 1;
|
||||
}
|
||||
// Autocorrect: a word ended by a space or punctuation is looked up.
|
||||
if let Some(list) = &autocorrect
|
||||
&& r.is_empty()
|
||||
@@ -392,8 +400,13 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A column, frame or page break ends its paragraph.
|
||||
let mut text = if text.contains(designcraft_doc::is_break_char) {
|
||||
designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next()))
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
// Typing next to a table anchor starts a paragraph of its own.
|
||||
let mut text = text.clone();
|
||||
let mut trail = 0;
|
||||
if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() {
|
||||
let pi = st.para_at(r.start);
|
||||
@@ -538,6 +551,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) };
|
||||
start..r.start
|
||||
};
|
||||
let r = with_break_pair(&st.text, r);
|
||||
if tracking && t.cell.is_none() {
|
||||
// Marked, not removed: the caret steps over the deleted text.
|
||||
let at = super::changes::mark_deleted(st, r.clone());
|
||||
@@ -551,6 +565,20 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A break character and the return after it end one paragraph: deleting either deletes both.
|
||||
fn with_break_pair(text: &str, r: std::ops::Range<usize>) -> std::ops::Range<usize> {
|
||||
let Some(del) = text.get(r.clone()) else { return r };
|
||||
let mut chars = del.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) {
|
||||
Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end,
|
||||
_ => r,
|
||||
},
|
||||
(Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1,
|
||||
_ => r,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_char(s: &str, i: usize) -> usize {
|
||||
s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
|
||||
}
|
||||
@@ -1291,6 +1319,47 @@ mod autocorrect_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod break_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one.
|
||||
#[test]
|
||||
fn inserted_break_ends_its_paragraph() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap();
|
||||
assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree");
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len());
|
||||
// At the end of a paragraph the break takes the place of its return.
|
||||
let at = story(&s).text.find("\nThree").unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap();
|
||||
let st = story(&s);
|
||||
assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree");
|
||||
assert_eq!(st.paras.len(), 3);
|
||||
st.check().unwrap();
|
||||
// Typing after the break goes to the next paragraph; deleting the break or its return
|
||||
// removes both.
|
||||
let at = "One\u{e011}".len();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree");
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap();
|
||||
s.execute("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree");
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap();
|
||||
s.execute("text.delete", &json!({"forward": true})).unwrap();
|
||||
assert_eq!(story(&s).text, "OnexTwoThree");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod language_tests {
|
||||
use serde_json::json;
|
||||
|
||||
Reference in New Issue
Block a user