diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index a1f21f975..ed6ace82e 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -193,85 +193,140 @@ impl Document { Ok((id, sid)) } - /// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text, - /// that text is appended to the story (as a new paragraph) and its old story is removed. + /// Thread `from` → `to`: `to` joins `from`'s story right after `from` (between `from` and the + /// frame that followed it, if any). If `to` held its own text, that text is appended to the + /// story (as new paragraphs) and its old story is removed; frames that followed `to` come + /// along, frames before it keep an empty story of their own. An empty (unassigned) `from` or + /// `to` becomes a text frame. `from`'s story keeps its id. pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> { if from == to { return Err(DocError::Invalid("cannot thread a frame to itself".into())); } - let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?; - // Convert unassigned/graphic-less frames into text frames. - let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() { - Content::Text(t) => Some(t.story), - Content::Unassigned => None, - _ => return Err(DocError::Invalid("can only thread to text or empty frames".into())), + let ts = self.frame_story(to)?; + let fs = match self.frame_story(from)? { + Some(s) => s, + None => self.give_empty_story(from)?, }; if ts == Some(fs) { return Err(DocError::Invalid("frames are already in the same story".into())); } - if let Some(ts) = ts { - let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?; - let other = Arc::unwrap_or_clone(other); - // Frames of the other story after `to` follow along; frames before it are left as empty new stories. - let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); - let before: Vec = other.frames[..pos_to].to_vec(); - let after: Vec = other.frames[pos_to..].to_vec(); - { - let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; - if !other.text.is_empty() { - st.text.push('\n'); - st.text.push_str(&other.text); - st.paras.extend(other.paras.iter().cloned()); - // Char runs: '\n' takes the last run's format, then the other story's runs. - if let Some(last) = st.chars.last_mut() { - last.len += 1; - } - st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned()); - st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone()))); - for n in &other.notes { - let id = st.next_note_id(); - st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() })); - } - st.anchors.extend(other.anchors.iter().cloned()); - st.xrefs.extend(other.xrefs.iter().cloned()); - st.index_refs.extend(other.index_refs.iter().cloned()); - st.objects.extend(other.objects.iter().cloned()); - st.rev += 1; - } - let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); - for (k, f) in after.iter().enumerate() { - st.frames.insert(at + k, *f); - } - } - for f in &after { - if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { - t.story = fs; - } - } - if !before.is_empty() { - let nid = StoryId(self.alloc()); - let mut ns = Story::new(nid); - ns.frames = before.clone(); - for f in &before { - if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { - t.story = nid; - } - } - self.stories.insert(nid, Arc::new(ns)); - } - } else { + let Some(ts) = ts else { let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); st.frames.insert(at, to); + st.rev += 1; let it = self.item_mut(to).ok_or(DocError::NoItem(to))?; it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() }); - } - if let Some(st) = self.story_mut(fs) { + return Ok(()); + }; + let mut other = Arc::unwrap_or_clone(self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?); + // Frames of the other story from `to` on follow along; frames before it are left as an empty new story. + let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); + let after = other.frames.split_off(pos_to); + let before = std::mem::take(&mut other.frames); + { + let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; + st.append_story(other); + let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); + for (k, f) in after.iter().enumerate() { + st.frames.insert(at + k, *f); + } st.rev += 1; } + for f in &after { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = fs; + } + } + if !before.is_empty() { + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = before.clone(); + for f in &before { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = nid; + } + } + self.stories.insert(nid, Arc::new(ns)); + } Ok(()) } + /// [`Document::thread`], except that an empty `from` alone in its story, threaded in front of + /// the first frame of `to`'s story, joins that story: the story with the text keeps its id + /// (and whatever refers to it). + pub fn thread_keeping_story(&mut self, from: ItemId, to: ItemId) -> Result<()> { + let fs = self.frame_story(from)?; + let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from])); + let ts = self.frame_story(to)?; + let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to)); + let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) }; + if let Some(fs) = fs { + self.stories.remove(&fs); + } + let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?; + st.frames.insert(0, from); + st.rev += 1; + let it = self.item_mut(from).ok_or(DocError::NoItem(from))?; + match it.text_frame_mut() { + Some(t) => t.story = ts, + None => it.content = Content::Text(TextFrame { story: ts, options: TextFrameOptions::default() }), + } + Ok(()) + } + + /// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on + /// document spreads or on the same parent spread, not in one story already, and no frame + /// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow). + pub fn check_thread(&self, from: ItemId, to: ItemId) -> Result<()> { + if from == to { + return Err(DocError::Invalid("cannot thread a frame to itself".into())); + } + for id in [from, to] { + let it = self.item(id).ok_or(DocError::NoItem(id))?; + if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine { + return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame"))); + } + } + let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) }; + let same_place = match (fl.spread, tl.spread) { + (SpreadRef::Doc(_), SpreadRef::Doc(_)) => true, + (a, b) => a == b, + }; + if !same_place { + return Err(DocError::Invalid("frames on a parent spread thread only to frames on the same parent".into())); + } + let story = |id: ItemId| self.item(id).and_then(|i| i.text_frame()).map(|t| t.story); + if story(from).is_some() && story(from) == story(to) { + return Err(DocError::Invalid("the frames are already in the same thread".into())); + } + if self.prev_frame(to).is_some() { + return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first"))); + } + Ok(()) + } + + /// The story of a frame that can take part in a thread: `Some` for a text frame, `None` for + /// an empty (unassigned) frame. + fn frame_story(&self, id: ItemId) -> Result> { + match &self.item(id).ok_or(DocError::NoItem(id))?.content { + Content::Text(t) => Ok(Some(t.story)), + Content::Unassigned => Ok(None), + _ => Err(DocError::Invalid("can only thread text frames or empty frames".into())), + } + } + + /// Make an empty frame a text frame with a new empty story. + fn give_empty_story(&mut self, id: ItemId) -> Result { + let sid = StoryId(self.alloc()); + let mut st = Story::new(sid); + st.frames.push(id); + let it = self.item_mut(id).ok_or(DocError::NoItem(id))?; + it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() }); + self.stories.insert(sid, Arc::new(st)); + Ok(sid) + } + /// Break the thread after `frame`: the following frames become a new empty story (the text /// stays with the first part and may become overset). pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { @@ -298,6 +353,27 @@ impl Document { Ok(()) } + /// Take `frame` out of its thread: the frames before and after it join up and keep the text, + /// `frame` stays on the page with a new empty story. + pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> { + let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; + { + let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; + if st.frames.len() < 2 { + return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame"))); + } + st.frames.retain(|f| *f != frame); + st.rev += 1; + } + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = vec![frame]; + self.stories.insert(nid, Arc::new(ns)); + let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?; + t.story = nid; + Ok(()) + } + /// Next / previous frame in a thread. pub fn next_frame(&self, frame: ItemId) -> Option { let st = self.story(self.item(frame)?.text_frame()?.story)?; diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index b376bf0e6..1d7067e8c 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -125,6 +125,59 @@ impl Story { self.rev += 1; end } + + /// Append the whole of `other` as new paragraphs after this story's text (threading two + /// stories merges them this way). Its formatting, notes, endnotes, editorial notes, markers, + /// anchored objects and tables come along; footnotes, endnotes, editorial notes and tables get + /// ids that are free in this story. An empty story simply takes `other`'s content. Frames stay. + pub fn append_story(&mut self, mut other: Story) { + if other.text.is_empty() { + return; + } + if self.text.is_empty() { + let (id, frames, rev, link) = (self.id, std::mem::take(&mut self.frames), self.rev, self.link.take()); + *self = Story { id, frames, rev: rev + 1, link, ..other }; + return; + } + if !other.tables.is_empty() { + let first = self.tables.keys().max().copied().unwrap_or(0) + 1; + let mut ids = std::collections::HashMap::new(); + for (new, (old, t)) in (first..).zip(std::mem::take(&mut other.tables)) { + let mut t = Arc::unwrap_or_clone(t); + t.id = new; + ids.insert(old, new); + self.tables.insert(new, Arc::new(t)); + } + for p in &mut other.paras { + p.table = p.table.and_then(|id| ids.get(&id).copied()); + } + } + self.text.push('\n'); + self.text.push_str(&other.text); + // The paragraph end takes the last run's format. + if let Some(last) = self.chars.last_mut() { + last.len += 1; + } + self.chars.extend(other.chars.into_iter().filter(|r| r.len > 0)); + self.paras.extend(other.paras); + for n in other.notes { + let id = self.next_note_id(); + self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); + } + for n in other.endnotes { + let id = self.endnotes.iter().map(|x| x.id).max().unwrap_or(0) + 1; + self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); + } + for n in other.editorial { + let id = self.editorial.iter().map(|x| x.id).max().unwrap_or(0) + 1; + self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() })); + } + self.anchors.extend(other.anchors); + self.xrefs.extend(other.xrefs); + self.index_refs.extend(other.index_refs); + self.objects.extend(other.objects); + self.rev += 1; + } } #[cfg(test)] diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c3ba44dfd..e248d72eb 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -42,6 +42,98 @@ fn thread_to_empty_frame_converts_it() { assert!(d.thread(a, a).is_err()); } +#[test] +fn threading_merges_tables_notes_and_endnotes() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap(); + for (sid, note) in [(sa, "Note A"), (sb, "Note B")] { + let st = d.story_mut(sid).unwrap(); + st.insert_note(3, note, ParaFormat::default()); + // Both stories hold a table with id 1. + let n = st.len(); + st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0)); + } + let st = d.story_mut(sb).unwrap(); + st.insert_endnote(2, "End B", ParaFormat::default()); + st.insert(1, &crate::NOTE_MARK.to_string()); + d.thread(a, b).unwrap(); + d.check().unwrap(); + let st = d.story(sa).unwrap(); + assert!(d.story(sb).is_none()); + assert_eq!(st.tables.len(), 2); + assert_eq!(st.notes.len(), 2); + assert_eq!(st.notes[1].text.text, "Note B"); + assert_eq!(st.endnotes.len(), 1); + assert_eq!(st.endnotes[0].text.text, "End B"); + assert_eq!(st.editorial.len(), 1); +} + +#[test] +fn empty_frame_threaded_before_a_story_joins_it_at_the_front() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread_keeping_story(a, b).unwrap(); + d.check().unwrap(); + assert!(d.story(sa).is_none()); + let st = d.story(sb).unwrap(); + assert_eq!(st.frames, vec![a, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, a2).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread(a2, b).unwrap(); + d.check().unwrap(); + let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.frames, vec![a, a2, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn threading_from_an_empty_frame_makes_it_a_text_frame() { + let mut d = doc(); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0))); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread(id, b).unwrap(); + d.check().unwrap(); + let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.frames, vec![id, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + d.thread(b, c).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + assert_eq!(d.story(sa).unwrap().frames, vec![a, c]); + assert_eq!(d.story(sa).unwrap().text, "words"); + let own = d.item(b).unwrap().text_frame().unwrap().story; + assert_ne!(own, sa); + assert!(d.story(own).unwrap().text.is_empty()); + // A frame alone in its story has no thread to leave. + assert!(d.remove_from_thread(b).is_err()); +} + #[test] fn removing_last_frame_deletes_story() { let mut d = doc(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 941473561..6413445c9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -50,6 +50,7 @@ mod strokes; mod style; pub mod table; pub mod text; +mod thread; mod toc; mod transitions; mod variables; @@ -184,6 +185,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(package::specs()); v.extend(print::specs()); v.extend(text::specs()); + v.extend(thread::specs()); v.extend(style::specs()); v.extend(table::specs()); v.extend(layout::specs()); diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs new file mode 100644 index 000000000..ff360e00a --- /dev/null +++ b/crates/engine/src/cmd/thread.rs @@ -0,0 +1,335 @@ +//! Threading text frames: thread a frame after another (their stories merge), break a thread, +//! take a frame out of its thread. The Selection tool's loaded text cursor runs these. + +use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId}; +use designcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param}; +use crate::{Result, Session}; + +/// Pages Shift-click autoflow may add before it gives up. +const AUTOFLOW_MAX_PAGES: usize = 2000; + +pub fn specs() -> Vec { + vec![ + cmd!( + "text.thread", + "Thread Text Frames", + [], + None, + "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}", + has_doc, + thread + ), + cmd!( + "text.unthread", + "Break Thread", + [], + None, + "{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it", + has_doc, + unthread + ), + cmd!( + "text.removeFromThread", + "Remove Frame from Thread", + [], + None, + "{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty", + has_doc, + remove_from_thread + ), + ] +} + +/// `frame` param, or the first selected text frame. +fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let id = id_param(p, "frame") + .or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame()))) + .ok_or_else(|| bad(cmd, "no text frame given or selected"))?; + if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) { + return Err(bad(cmd, format!("{id} is not a text frame"))); + } + Ok(id) +} + +/// A rect param made safe for a frame: finite, ordered, at least half a point each way. +fn frame_rect(p: &Value) -> Result> { + let Some(r) = rect_param(p, "rect") else { return Ok(None) }; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return Err(bad("text.thread", "rect must be finite")); + } + let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1)); + let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1)); + Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5)))) +} + +fn thread(s: &mut Session, p: &Value) -> Result { + let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?); + let sr = spread_param(p, "spread"); + let autoflow = bool_or(p, "autoflow", false); + let layer = s.doc()?.active_layer; + s.edit(|d, sel| { + let mut new_frame = || -> Result { + let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?; + let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }; + Ok(d.add_text_frame(sr, r, layer, "", para)?.0) + }; + let (from, to, joined) = match (from, to) { + (Some(f), Some(t)) if rect.is_none() => (f, t, t), + (Some(f), None) => { + let t = new_frame()?; + (f, t, t) + } + (None, Some(t)) => { + let f = new_frame()?; + (f, t, f) + } + _ => return Err(bad("text.thread", "give two of from, to and rect")), + }; + d.check_thread(from, to)?; + d.thread_keeping_story(from, to)?; + let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?; + let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES)? } else { 0 }; + *sel = Selection::items(vec![joined]); + Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages})) + }) +} + +fn overset(d: &Document, sid: StoryId) -> bool { + designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some() +} + +fn unthread(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "text.unthread"; + let frame = frame_param(s, p, CMD)?; + let d0 = &s.doc()?.doc; + let at = match str_param(p, "side").unwrap_or("after") { + "after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?, + "before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?, + other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))), + }; + s.edit(|d, _| { + d.unthread_after(at)?; + let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?; + Ok(json!({"story": sid.0, "overset": overset(d, sid)})) + }) +} + +fn remove_from_thread(s: &mut Session, p: &Value) -> Result { + let frame = frame_param(s, p, "text.removeFromThread")?; + s.edit(|d, _| { + d.remove_from_thread(frame)?; + Ok(json!({})) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s + } + + fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) { + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap(); + (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()) + } + + fn story(s: &mut Session, frame: u64) -> serde_json::Value { + s.execute("story.get", &json!({"frame": frame})).unwrap() + } + + fn frames(s: &mut Session, frame: u64) -> Vec { + story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() + } + + const LONG: &str = "Threaded text keeps flowing from one frame into the next one. "; + + #[test] + fn thread_into_an_empty_frame_and_undo() { + let mut s = session(); + let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10)); + let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset"); + let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + assert_eq!(r["story"].as_u64(), Some(sid)); + assert_eq!(frames(&mut s, a), [a, b]); + let d = &s.doc().unwrap().doc; + assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame()); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]); + // The text now reaches the second frame. + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default()); + assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame"); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a]); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame()); + } + + #[test] + fn thread_into_a_text_frame_merges_its_story() { + let mut s = session(); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First"); + let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second"); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + let st = story(&mut s, b); + assert_eq!(st["id"].as_u64(), Some(sa)); + assert_eq!(st["text"], "First\nSecond"); + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn thread_into_the_middle_of_a_thread() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], ""); + s.execute("text.thread", &json!({"from": a, "to": c})).unwrap(); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b, c]); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn bad_threads_are_errors_and_change_nothing() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a"); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b"); + let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x"); + let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y"); + let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap(); + let group = { + let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone(); + let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [p, q]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.doc().unwrap().selection.items[0].0 + }; + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": x, "to": y})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let doc = s.doc().unwrap().doc.clone(); + for p in [ + json!({"from": a, "to": a}), + json!({"from": a, "to": g}), + json!({"from": g, "to": a}), + json!({"from": a, "to": group}), + // Earlier in the same thread. + json!({"from": b, "to": a}), + // Text already flows into y from x. + json!({"from": a, "to": y}), + json!({"from": a, "to": 999_999}), + json!({"from": a}), + json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}), + json!({"rect": [0, 0, 10, 10]}), + json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}), + json!({"from": "a", "to": [1]}), + json!([1, 2]), + ] { + assert!(s.execute("text.thread", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(*s.doc().unwrap().doc, *doc); + } + + #[test] + fn thread_a_new_frame_after_or_in_front() { + let mut s = session(); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap(); + let n = r["to"].as_u64().unwrap(); + assert_eq!(frames(&mut s, a), [a, n]); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds(); + assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0)); + // In front of the first frame: the story keeps its id and text. + let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap(); + let m = r["from"].as_u64().unwrap(); + assert_eq!(r["story"].as_u64(), Some(sa)); + assert_eq!(frames(&mut s, a), [m, a, n]); + assert_eq!(story(&mut s, a)["text"], LONG.repeat(8)); + s.doc().unwrap().doc.check().unwrap(); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a, n]); + } + + use designcraft_geom::Rect; + + #[test] + fn thread_with_autoflow_adds_pages_until_the_text_fits() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300)); + let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false); + assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}"); + assert!(frames(&mut s, a).len() >= 3); + s.doc().unwrap().doc.check().unwrap(); + // One undo removes the frames and the pages. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a]); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + } + + #[test] + fn unthread_after_and_before() { + let mut s = session(); + let text = LONG.repeat(12); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text); + let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], ""); + let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": b, "to": c})).unwrap(); + // Break after the first frame: b and c stay threaded to each other, empty. + let r = s.execute("text.unthread", &json!({"frame": a})).unwrap(); + assert_eq!(r["story"].as_u64(), Some(sa)); + assert_eq!(r["overset"], true); + assert_eq!(frames(&mut s, a), [a]); + assert_eq!(story(&mut s, a)["text"], text); + assert_eq!(frames(&mut s, b), [b, c]); + assert_eq!(story(&mut s, b)["text"], ""); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b, c]); + // Break before the last frame, with the frame selected. + s.execute("selection.set", &json!({"ids": [c]})).unwrap(); + s.execute("text.unthread", &json!({"side": "before"})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b]); + assert_eq!(frames(&mut s, c), [c]); + // Nothing to break. + assert!(s.execute("text.unthread", &json!({"frame": b})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err()); + } + + #[test] + fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() { + let mut s = session(); + let text = LONG.repeat(12); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text); + let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], ""); + let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": b, "to": c})).unwrap(); + s.execute("text.removeFromThread", &json!({"frame": b})).unwrap(); + assert_eq!(frames(&mut s, a), [a, c]); + assert_eq!(story(&mut s, a)["text"], text); + assert_eq!(story(&mut s, b)["text"], ""); + assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.clear", &json!({"ids": [b]})).unwrap(); + assert_eq!(frames(&mut s, a), [a, c]); + assert_eq!(story(&mut s, a)["text"], text); + s.doc().unwrap().doc.check().unwrap(); + } +}