//! Edit menu: undo/redo, selection, clear, clipboard. use std::sync::Arc; use designcraft_doc::{ItemId, Selection, SpreadRef}; use serde_json::{Value, json}; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok}; use crate::{HistoryEntry, Result, Session}; fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } fn can_redo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into()) } fn has_clip(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; let text = s.text_clipboard.is_some() && s.active().is_some_and(|d| d.selection.text.is_some()); if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) } } fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> { if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) { return Ok(()); } has_selection(s) } /// Plain text of a story slice: markers and anchored objects dropped, footnote text omitted. fn plain(st: &designcraft_doc::Story) -> String { st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect() } /// Copy the selected text with its formatting. Returns its plain text (for the system clipboard). fn copy_text(s: &mut Session) -> Result> { let st = s.doc()?; let Some(t) = st.selection.text.filter(|t| !t.is_caret()) else { return Ok(None) }; let Some(story) = st.doc.text_story(t.story, t.cell) else { return Ok(None) }; let slice = story.extract(t.range()); let text = plain(&slice); s.text_clipboard = Some((Arc::new(slice), text.clone())); Ok(Some(text)) } /// Plain text for the system clipboard after copying objects: the copied text frames' stories, /// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the /// copied objects", not as text. pub fn copied_objects_text(s: &Session) -> String { let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() }; let mut stories = Vec::new(); for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() { it.walk(&mut |i| { if let Some(tf) = i.text_frame() && !stories.contains(&tf.story) { stories.push(tf.story); } }); } let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::>().join("\n"); if text.is_empty() { '\u{FFFC}'.to_string() } else { text } } /// `text` (the system clipboard's) is what the last copy put there: objects were copied last and /// it is their placeholder, so Paste stands for the copied objects. fn is_objects_placeholder(s: &Session, text: &str) -> bool { s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n") } /// Paste text at the insertion point: the formatted copy when the system clipboard still holds /// what was copied here (or `formatted` is forced), else plain text in the insertion format. /// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their /// placeholder; other text was copied in another app since. Objects that can't be anchored (text /// frames, into a table cell) paste as their placeholder text. fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?; let clip = s.text_clipboard.clone(); let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x)); if own && t.cell.is_none() && let Some(items) = s.clipboard.clone() { let list: Vec = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default(); if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) { return s.edit(|d, sel| { let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); st.delete(r.start.min(st.len())..r.end.min(st.len())); // Fresh ids, story and asset copies for every paste, as on the page. let mut stories = std::collections::HashMap::new(); let fresh: Vec = list .iter() .map(|i| { let mut i = i.clone(); super::object::renumber(d, &items, &mut i, &mut stories); if d.layer(i.layer).is_none() { i.layer = d.default_layer(); } i }) .collect(); let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?; sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); Ok(json!({"pos": end, "anchored": list.len()})) }); } } let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); // Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes. if use_clip && let Some((slice, _)) = clip { let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; return s.edit(|d, sel| { let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); let r = r.start.min(st.len())..r.end.min(st.len()); st.delete(r.clone()); let end = st.insert_story(r.start, &slice); sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); Ok(json!({"pos": end, "formatted": true})) }); } // U+FFFC stands for objects with no text. let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text }; let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; let text = outside_text(&text)?; s.execute("text.insert", &json!({"text": text, "raw": true})) } /// Text from the system clipboard as story text: Unix line ends, and none of the private-use /// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and /// variables, since a stray one would be read as a marker without its payload. fn outside_text(text: &str) -> Result { let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) } } /// Pasted text with no insertion point: a new text frame holding it, as wide as the first column /// of the page's margin area and as tall as the text (at most the margin area's height), centred /// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With /// `slice` (the text copied here), the frame's story is that formatted copy. fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result { use designcraft_geom::{Point, Rect, shapes}; const LIMIT: f64 = 1.0e5; let sr = super::spread_param(p, "edit.paste")?; let at = super::point_param(p, "at") .filter(|a| a.x.is_finite() && a.y.is_finite()) .map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT))); let lid = s.doc()?.active_layer; let cache = s.cache.clone(); let text = if slice.is_some() { String::new() } else { outside_text(text)? }; s.edit(|d, sel| { let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?; let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first()); let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?; let margins = page.margin_rect(); let column = page.column_rects().first().copied().unwrap_or(margins); let w = column.width().max(12.0); let max_h = margins.height().max(12.0); let c = at.unwrap_or(Point::new(column.center().x, margins.center().y)); let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0); let para = match slice.and_then(|sl| sl.paras.first()) { Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() }, None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }, }; let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?; if let Some(sl) = slice { d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl); } // Shrink to the composed text unless it already fills the frame. let composed = cache.get(d, sid, None); if !composed.is_overset() && let Some(ft) = composed.frame(id) { let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]); let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h); if h.is_finite() && h < max_h && let Some(it) = d.item_mut(id) { it.path = shapes::rectangle(rect(h)); // Keep the full height if the last line no longer fits. if cache.get(d, sid, None).is_overset() && let Some(it) = d.item_mut(id) { it.path = shapes::rectangle(rect(max_h)); } } } *sel = Selection::items(vec![id]); Ok(json!({"id": id.0, "story": sid.0})) }) } fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { let mut out = st.clone(); let marks = [ designcraft_doc::FOOTNOTE_REF, designcraft_doc::ENDNOTE_REF, designcraft_doc::NOTE_MARK, designcraft_doc::XREF_MARK, designcraft_doc::TABLE_ANCHOR, ]; while let Some(i) = out.text.find(marks) { let n = out.text[i..].chars().next().map_or(1, char::len_utf8); out.delete(i..i + n); } out } pub fn specs() -> Vec { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| { let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| super::bad("edit.undo", "nothing to undo"))?; st.history.redo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }); let mut doc = e.doc; super::rulers::keep_zero_point(&st.doc, &mut doc); st.doc = doc; st.selection = e.selection; st.bump_revision(); // Pending typing overrides belong to the current edit state, not a restored snapshot. s.typing_format = None; Ok(json!({"undone": e.label})) }), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| { let st = s.doc_mut()?; let e = st.history.redo.pop().ok_or_else(|| super::bad("edit.redo", "nothing to redo"))?; st.history.undo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }); let mut doc = e.doc; super::rulers::keep_zero_point(&st.doc, &mut doc); st.doc = doc; st.selection = e.selection; st.bump_revision(); s.typing_format = None; Ok(json!({"redone": e.label})) }), cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { let ids = ids_param(p, "ids").unwrap_or_default(); let add = bool_or(p, "add", false); let content = bool_or(p, "content", false); let st = s.doc_mut()?; let ids: Vec = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect(); if add { for i in ids { if !st.selection.items.contains(&i) { st.selection.items.push(i); } } } else { st.selection = Selection { items: ids, content, ..Default::default() }; } st.selection.text = None; st.selection.cells = None; st.bump_revision(); ok() }), cmd!(noundo "selection.toggle", "Toggle Selection", [], None, "{id}", has_doc, |s, p| { let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.toggle", "missing id"))?; let st = s.doc_mut()?; st.selection.text = None; st.selection.cells = None; if let Some(i) = st.selection.items.iter().position(|x| *x == id) { st.selection.items.remove(i); // A key object needs another object to align to it. if st.selection.key == Some(id) || st.selection.items.len() < 2 { st.selection.key = None; } } else { st.selection.items.push(id); } st.bump_revision(); ok() }), cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| { let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?; let st = s.doc_mut()?; if !st.selection.items.contains(&id) { return Err(super::bad("selection.key", "the key object must be selected")); } if st.selection.items.len() < 2 { return Err(super::bad("selection.key", "a key object needs at least two selected objects")); } st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) }; st.bump_revision(); ok() }), cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| { let st = s.doc_mut()?; if let Some(t) = st.selection.text { let len = st.doc.text_story(t.story, t.cell).map(|x| x.len()).unwrap_or(0); st.selection.text = Some(designcraft_doc::TextSel { anchor: 0, focus: len, ..t }); } else { let mut ids = Vec::new(); let parents = st.editing_parents; let refs: Vec = st.doc.spread_refs().filter(|r| matches!(r, SpreadRef::Parent(_)) == parents).collect(); for r in refs { if let Some(sp) = st.doc.spread(r) { for it in &sp.items { let locked = it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible); if !locked && !it.hidden { ids.push(it.id); } } } } st.selection = Selection::items(ids); } st.bump_revision(); ok() }), cmd!(noundo "edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", has_doc, |s, _| { let st = s.doc_mut()?; st.selection = Selection::default(); st.bump_revision(); ok() }), cmd!( "edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text or ruler guides)", has_doc, |s, p| { if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { return super::text::delete_selection(s); } if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() { return s.execute("guide.delete", &json!({})); } let ids = super::targets(s, p)?; // With a placed graphic itself selected (Direct Selection), only the graphic goes: its // frame stays, empty and selected (as in InDesign). Other items are deleted. let content = s.doc()?.selection.content && p.get("ids").is_none(); s.edit(|d, sel| { let mut kept = Vec::new(); for id in &ids { if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { it.content = designcraft_doc::Content::Unassigned; kept.push(*id); } else { let _ = d.remove_item(*id); } } *sel = Selection::items(kept); Ok(json!({"deleted": ids.len()})) }) } ), cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| { if let Some(text) = copy_text(s)? { return Ok(json!({"text": text})); } copy_items(s)?; s.text_clipboard = None; ok() }), cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection_or_text, |s, p| { if let Some(text) = copy_text(s)? { super::text::delete_selection(s)?; return Ok(json!({"text": text})); } copy_items(s)?; s.execute("edit.clear", p) }), cmd!( "edit.pasteWithoutFormatting", "Paste without Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?} — plain text at the insertion point, in the format there", super::has_text, |s, p| paste_text(s, super::str_param(p, "text").map(str::to_string), false) ), cmd!( "edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{inPlace?: bool, spread?: index (0), text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), at?: [x, y] (spread coords: the new text frame's centre)}", // The system clipboard may hold text from another app, so Paste is always available. has_doc, |s, p| { let text = super::str_param(p, "text").filter(|t| !t.is_empty()); if s.doc()?.selection.text.is_some() { return paste_text(s, text.map(str::to_string), true); } // The placeholder of an object copy pastes the copied objects. if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) { // What our own text copy put on the clipboard keeps its formatting. let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); return paste_text_frame(s, p, text, slice.as_deref()); } let Some(clip) = s.clipboard.clone() else { return Err(super::bad("edit.paste", "clipboard is empty")); }; let off = if bool_or(p, "inPlace", false) { 0.0 } else { 12.0 }; let to = super::spread_param(p, "edit.paste")?; let from = s.clipboard_origin; let ids: Vec = clip.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); s.edit(|d, sel| { // The same place on another spread is the same distance from its spine or left edge. let dx = d.spread(to).map_or(0.0, |sp| paste_origin(sp) - from); let new = super::object::duplicate_from(d, &clip, &ids, to, designcraft_geom::Vec2::new(dx + off, off))?; *sel = Selection::items(new.clone()); Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) }) } ), cmd!( "edit.pasteInto", "Paste Into", ["Edit"], Some("Cmd+Alt+V"), "{id?} — the copied objects become the content of the selected frame (or `id`), clipped by it", has_clip, paste_into ), cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Alt+Shift+V"), "{spread?: index (0)}", has_clip, |s, p| s .execute("edit.paste", &json!({"inPlace": true, "spread": p.get("spread")}))), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Alt+Shift+D"), "{}", has_selection, |s, _| s .execute("transform.move", &json!({"dx": 12.0, "dy": 12.0, "copy": true}))), // Live previews (a panel slider being dragged): the change shows on the page, and only // `edit.commitPreview` makes it one undo step. cmd!( query "edit.preview", "Preview Change", [], None, "{label, command, params?} — run `command` on the document as it was before the preview began, replacing the last preview; the first call opens a preview named `label` (the undo step's name)", has_doc, preview ), cmd!(query "edit.commitPreview", "Commit Preview", [], None, "{} — the open preview becomes one undo step", has_doc, |s, _| { s.commit_interaction()?; ok() }), cmd!(query "edit.cancelPreview", "Cancel Preview", [], None, "{} — the document goes back to how it was before the preview", has_doc, |s, _| { s.cancel_interaction(); ok() }), ] } /// Put the selected items on the clipboard, with the x Paste measures them from on their spread. fn copy_items(s: &mut Session) -> Result<()> { let clip = clip_doc(s)?; let st = s.doc()?; let spread = st.selection.items.iter().find_map(|id| st.doc.find(*id)).and_then(|loc| st.doc.spread(loc.spread)); s.clipboard_origin = spread.map_or(0.0, paste_origin); s.clipboard = Some(Arc::new(clip)); Ok(()) } /// The x a pasted object keeps its distance from: the spine, or the left edge of single-sided /// pages, which have none. fn paste_origin(sp: &designcraft_doc::Spread) -> f64 { if sp.pages.iter().all(|p| p.side == designcraft_doc::PageSide::Single) { sp.bounds().x0 } else { sp.spine_x() } } /// `edit.preview`: open the preview (unless one of the same name is open) and show `command` on it. fn preview(s: &mut Session, p: &Value) -> Result { let label = super::str_param(p, "label").filter(|l| !l.is_empty()).ok_or_else(|| super::bad("edit.preview", "missing label"))?; let command = super::str_param(p, "command").ok_or_else(|| super::bad("edit.preview", "missing command"))?; // Only an edit can be previewed: a query, undo or another preview command would not be one undo step. if !super::find_command(command).is_some_and(|c| c.undoable) { return Err(super::bad("edit.preview", format!("{command} is not an edit that can be previewed"))); } let params = p.get("params").cloned().unwrap_or_else(|| json!({})); let opened = match s.doc()?.interaction.as_ref().map(|i| i.label.as_str()) { None => { s.begin_interaction(label)?; true } Some(open) if open == label => false, // A tool's drag (or another preview) is in progress: leave it alone. Some(open) => return Err(super::bad("edit.preview", format!("{open} is in progress"))), }; if let Err(e) = s.preview(command, params) { // A preview this call opened must not stay open: later edits would get no undo step. if opened { s.cancel_interaction(); } return Err(e); } ok() } /// A document holding copies of the selected items (and their stories) on spread 0. pub(crate) fn clip_doc(s: &Session) -> Result { let st = s.doc()?; let mut d = (*st.doc).clone(); let keep: Vec = st.selection.items.clone(); // Gather the selected items (in their spread coordinates, nested ones too) into spread 0. let mut items = Vec::new(); for id in &keep { let (Some(it), Some(loc)) = (st.doc.item(*id), st.doc.find(*id)) else { continue }; let parent = st.doc.parent_xf(&loc); items.push(Arc::new(match &it.content { designcraft_doc::Content::Graphic(g) if st.selection.content => graphic_alone(it, g, parent), _ => designcraft_doc::Item { xf: parent * it.xf, ..it.clone() }, })); } for sp in d.spreads.iter_mut() { Arc::make_mut(sp).items.clear(); } // Kept frames (including inside groups). let mut frames = std::collections::HashSet::new(); for it in &items { it.walk(&mut |i| { if let designcraft_doc::Content::Text(_) = &i.content { frames.insert(i.id); } }); } if let Some(sp) = d.spreads.first_mut() { Arc::make_mut(sp).items = items; } // Parent spreads aren't part of a clipping; stories keep only the copied frames. for p in d.parents.iter_mut() { Arc::make_mut(p).items.clear(); } d.stories.retain(|_, st| st.frames.iter().any(|f| frames.contains(f))); for st in d.stories.values_mut() { Arc::make_mut(st).frames.retain(|f| frames.contains(f)); } // Graphics on the items and in the kept stories (anchored objects, graphic cells, notes). let assets = d.used_assets(); d.assets.retain(|k, _| assets.contains(k)); d.hyperlinks.clear(); d.bookmarks.clear(); Ok(d) } /// A placed graphic on its own, as InDesign copies one selected with Direct Selection: a frame of /// the whole graphic's bounds (uncropped), where the graphic sits on the spread (`parent` is the /// frame's parent transform), without the frame's fill, stroke or effects. fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, parent: designcraft_geom::Affine) -> designcraft_doc::Item { let r = designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1); let mut it = designcraft_doc::Item::new(frame.id, frame.layer, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); it.xf = parent * frame.xf * g.xf; it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { xf: designcraft_geom::Affine::IDENTITY, auto_fit: designcraft_doc::Fitting::None, crop: [0.0; 4], ..g.clone() }); it } #[cfg(test)] mod text_clipboard_tests { use super::*; fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId { let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story } #[test] fn paste_text_with_no_insertion_point_makes_a_text_frame() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); // Text from another app, nothing copied here: a new, selected text frame. let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); let id = ItemId(r["id"].as_u64().unwrap()); let d = s.doc().unwrap(); assert_eq!(d.selection.items, vec![id]); assert!(d.selection.text.is_none(), "selected as an object"); let it = d.doc.item(id).unwrap(); assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); let page = &d.doc.spreads[0].pages[0]; let (b, m) = (it.bounds(), page.margin_rect()); assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(items(&s), 0, "one undo removes it"); // Centred on `at`; hostile numbers are clamped. let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); // Copied objects paste when no text is given. s.execute("edit.deselectAll", &json!({})).unwrap(); let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); let n = items(&s); let r = s.execute("edit.paste", &json!({})).unwrap(); assert_eq!(r["ids"].as_array().unwrap().len(), 1); assert_eq!(items(&s), n + 1); assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); s.doc().unwrap().doc.check().unwrap(); } /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). #[test] fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let story = frame(&mut s, "ab"); let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); let marker = copied_objects_text(&s); s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); assert!(r.get("anchored").is_none(), "{r}"); assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); // What the object copy put on the system clipboard still pastes the objects. assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); // With no insertion point too: the rectangle, not a text frame holding the marker. s.execute("edit.deselectAll", &json!({})).unwrap(); let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); let id = ItemId(r["ids"][0].as_u64().unwrap()); assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); s.doc().unwrap().doc.check().unwrap(); // Paste without Formatting inserts the text given; empty text is no text. s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); } /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. #[test] fn copied_text_frames_paste_at_a_caret_as_their_text() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let target = frame(&mut s, "ab"); let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { s.execute("selection.set", &json!({"ids": ids})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); s.execute("edit.paste", &json!({"text": text})).unwrap() }; let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); // The same text copied in another app, and the system clipboard read back as absent. let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); assert!(r.get("anchored").is_none(), "{r}"); s.execute("edit.paste", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); // A group with a text frame inside, its own placeholder on the system clipboard. let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); let r = copy_and_paste(&mut s, json!([group]), Value::Null); assert!(r.get("anchored").is_none(), "{r}"); assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); s.doc().unwrap().doc.check().unwrap(); } #[test] fn outside_text_never_pastes_markers() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); // Private-use characters from another app would read as footnote, table or variable markers. let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); s.doc().unwrap().doc.check().unwrap(); let b = frame(&mut s, "Target"); s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); s.execute("edit.paste", &json!({"text": dirty})).unwrap(); s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); s.doc().unwrap().doc.check().unwrap(); } #[test] fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let a = frame(&mut s, "Bold words\nSecond"); s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); s.execute("type.bold", &json!({})).unwrap(); s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); s.execute("edit.deselectAll", &json!({})).unwrap(); let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); let d = s.doc().unwrap(); let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); assert_eq!(st.text, "Bold words\nSecond"); assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); assert_eq!(st.char_format_at(7).over.font_style, None); assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); d.doc.check().unwrap(); } #[test] fn formatted_copy_paste_and_plain_paste() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let a = frame(&mut s, "Bold words here."); let b = frame(&mut s, "Target: ."); s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); s.execute("type.bold", &json!({})).unwrap(); // A footnote inside the copied range travels with it. s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap(); s.execute("footnote.insert", &json!({"text": "n"})).unwrap(); let fl = designcraft_doc::FOOTNOTE_REF.len_utf8(); s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap(); let r = s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(r["text"], "Bold words"); // Formatted paste when the system clipboard matches. s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap(); s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap(); let st = s.doc().unwrap().doc.story(b).unwrap().clone(); assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF)); assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold")); assert_eq!(st.notes.len(), 1); assert_eq!(st.notes[0].text.text, "n"); // Other text on the system clipboard pastes as plain text. s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap(); assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste"); // Paste without formatting takes the insertion format. s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap(); s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); let st = s.doc().unwrap().doc.story(b).unwrap().clone(); assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text); assert_eq!(st.char_format_at(1).over.font_style, None); // Cut removes and copies. s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap(); assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold"); assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget")); s.doc().unwrap().doc.check().unwrap(); } fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let source = frame(&mut s, "aéz"); s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); (s, source) } fn assert_child_note_paste( s: &mut Session, source: designcraft_doc::StoryId, parent: designcraft_doc::StoryId, cell: designcraft_doc::CellAddr, endpoints: (usize, usize), ) { let before = s.doc().unwrap().doc.clone(); let selected = s.doc().unwrap().selection.text.unwrap(); assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); assert_eq!(selected.frame, None); let before_child = before.text_story(parent, Some(cell)).unwrap(); assert_eq!(before_child.text, "L----R"); let clipboard = s.text_clipboard.clone(); let history = s.execute("document.history", &json!({})).unwrap(); assert_eq!(history["redo"], json!([])); let result = s.execute("edit.paste", &json!({})).unwrap(); let after = s.doc().unwrap().doc.clone(); let child = after.text_story(parent, Some(cell)).unwrap(); assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); assert_eq!(child.len(), 6); assert_eq!(result, json!({"pos": 5, "formatted": true})); assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); assert!(child.endnotes.is_empty() && child.editorial.is_empty()); for pos in [1, 2, 4] { let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); assert_eq!(format.over.font_style.as_deref(), Some("Bold")); } // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. for pos in [0, 5] { let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); assert_eq!(new_format, old_format); } assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); assert_eq!(after.story(source), before.story(source)); assert_eq!(s.text_clipboard, clipboard); let mut pasted_history = history.clone(); pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); assert_eq!(s.doc().unwrap().doc, before); let mut undone_history = history; undone_history["redo"] = json!(["Paste"]); assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); // This filter does not change which selection Undo restores. assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); assert_eq!(s.doc().unwrap().doc, after); assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); } #[test] fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { let (mut s, source) = note_clipboard_session(); let parent = frame(&mut s, "PZ"); s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); s.execute("table.nextCell", &json!({})).unwrap(); s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); s.execute("table.prevCell", &json!({})).unwrap(); s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); s.execute("text.move", &json!({"dir": "right"})).unwrap(); for _ in 0..4 { s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); } let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); let mut expected_table = (*before_table).clone(); expected_table.cells[0].text = table.cells[0].text.clone(); assert_eq!(table.as_ref(), &expected_table); // The same clipboard must still retain both markers when pasted into an ordinary root story. let root = frame(&mut s, "L----R"); s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); } #[test] fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { let (mut s, source) = note_clipboard_session(); let parent = frame(&mut s, "PZ"); let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); s.execute("text.move", &json!({"dir": "left"})).unwrap(); for _ in 0..4 { s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); } let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); let after = s.doc().unwrap().doc.story(parent).unwrap(); assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); assert_eq!(after.note(neighbor), before.note(neighbor)); assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); } #[test] fn change_case_keeps_runs() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let a = frame(&mut s, "the quick brown fox. jumps over straße"); s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap(); s.execute("type.italic", &json!({})).unwrap(); let len = s.doc().unwrap().doc.story(a).unwrap().len(); s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße"); s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße"); s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); let st = s.doc().unwrap().doc.story(a).unwrap().clone(); assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE"); assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic")); assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); st.check().unwrap(); } #[test] fn change_case_leaves_georgian_without_initial_capitals() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); let len = s.doc().unwrap().doc.story(a).unwrap().len(); s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); } } fn paste_into(s: &mut Session, p: &Value) -> Result { use super::object::RemoveKeep; let clip = s.clipboard.clone().ok_or_else(|| super::bad("edit.pasteInto", "the clipboard holds no objects"))?; let target = match super::id_param(p, "id") { Some(id) => id, None => { let st = s.doc()?; match st.selection.items.as_slice() { [one] => *one, _ => return Err(super::bad("edit.pasteInto", "select one frame to paste into")), } } }; let ids: Vec = clip.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); if ids.is_empty() { return Err(super::bad("edit.pasteInto", "the clipboard holds no objects")); } s.edit(|d, sel| { let loc = d.find(target).ok_or(designcraft_doc::DocError::NoItem(target))?; let frame = d.item_at(&loc).ok_or(designcraft_doc::DocError::NoItem(target))?.clone(); if frame.is_group() || frame.is_text_frame() { return Err(super::bad("edit.pasteInto", "paste into a graphic or empty frame (not a group or text frame)")); } let world = d.parent_xf(&loc) * frame.xf; // Copies on the frame's spread (stories and images come along), then nested in the frame. let new = super::object::duplicate_from(d, &clip, &ids, loc.spread, designcraft_geom::Vec2::ZERO)?; let mut kids = Vec::new(); for id in &new { kids.push(d.remove_item_keep_story(*id)?); } // Centre the content on the frame when it doesn't overlap it (like InDesign). let fb = frame.bounds(); let cb = kids.iter().map(|k| k.bounds()).reduce(|a, b| a.union(b)).unwrap_or(fb); let shift = if cb.intersect(fb).area() > 0.0 { designcraft_geom::Vec2::ZERO } else { fb.center().to_vec2() - cb.center().to_vec2() }; let inv = world.inverse(); let kids: Vec> = kids .into_iter() .map(|mut k| { k.xf = inv * designcraft_geom::Affine::translate(shift) * k.xf; Arc::new(k) }) .collect(); let it = d.item_mut(target).ok_or(designcraft_doc::DocError::NoItem(target))?; it.content = designcraft_doc::Content::Group { items: kids }; *sel = Selection::items(vec![target]); Ok(json!({"id": target.0, "items": new.len()})) }) } #[cfg(test)] mod paste_into_tests { use super::*; #[test] fn paste_into_nests_and_clips() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); let frame = s.execute("frame.create", &json!({"rect": [150, 150, 250, 250]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [a]})).unwrap(); s.execute("edit.cut", &json!({})).unwrap(); s.execute("selection.set", &json!({"ids": [frame]})).unwrap(); s.execute("edit.pasteInto", &json!({})).unwrap(); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.spreads[0].items.len(), 1, "the black square is inside the frame now"); let f = d.item(ItemId(frame)).unwrap(); assert!(f.has_nested_items() && !f.is_group()); assert_eq!(f.bounds(), designcraft_geom::Rect::new(150.0, 150.0, 250.0, 250.0), "bounds stay the frame's"); // Rendered clipped: black inside the frame, nothing outside it. let cache = designcraft_compose::Cache::new(); let mut r = designcraft_render::Renderer::new(); let img = r.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); assert!(img.pixel(200, 200)[0] < 90, "{:?}", img.pixel(200, 200)); assert!(img.pixel(120, 120)[0] > 200, "{:?}", img.pixel(120, 120)); s.doc().unwrap().doc.check().unwrap(); } #[test] fn a_selected_graphic_copies_and_clears_on_its_own() { let dir = std::env::temp_dir().join(format!("dc-graphic-alone-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let png = dir.join("photo.png"); std::fs::write(&png, designcraft_render::Rendered { width: 200, height: 100, pixels: vec![40u8; 200 * 100 * 4] }.to_png()).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); // A square frame showing the middle of a wide photo (cropped left and right). let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [frame]})).unwrap(); s.execute("object.stroke", &json!({"ids": [frame], "swatch": "[Black]", "weight": 4})).unwrap(); // Direct Selection on the photo, Copy: the whole photo, where it is, without the frame's stroke. s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); let clip = s.clipboard.clone().unwrap(); let it = &clip.spreads[0].items[0]; assert!(matches!(&it.content, designcraft_doc::Content::Graphic(g) if g.crop == [0.0; 4])); assert_eq!(it.bounds(), designcraft_geom::Rect::new(50.0, 100.0, 250.0, 200.0)); assert!(it.stroke.weight == 0.0 || it.stroke.swatch == "[None]", "{:?}", it.stroke); // Paste Into a shape over it: the photo lands in place, inside the shape. let shape = s.execute("frame.create", &json!({"rect": [60, 90, 240, 210], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [shape]})).unwrap(); s.execute("edit.pasteInto", &json!({})).unwrap(); let d = s.doc().unwrap().doc.clone(); let kid = d.item(ItemId(shape)).unwrap().children()[0].clone(); let at = d.parent_xf(&d.find(kid.id).unwrap()) * kid.xf; assert!((at * designcraft_geom::Point::new(0.0, 0.0) - designcraft_geom::Point::new(50.0, 100.0)).hypot() < 1e-6); assert!((at * designcraft_geom::Point::new(200.0, 100.0) - designcraft_geom::Point::new(250.0, 200.0)).hypot() < 1e-6); // Delete with the photo selected: the frame stays, empty. s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 1); let d = s.doc().unwrap().doc.clone(); assert!(matches!(d.item(ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned)); assert_eq!(s.doc().unwrap().selection, Selection::items(vec![ItemId(frame)])); d.check().unwrap(); let _ = std::fs::remove_dir_all(&dir); } } #[cfg(test)] mod paste_tests { use super::*; use designcraft_geom::Rect; fn pasted(s: &mut Session, id: &str, p: Value) -> (SpreadRef, Rect) { let new = ItemId(s.execute(id, &p).unwrap()["ids"][0].as_u64().unwrap()); let d = &s.doc().unwrap().doc; (d.find(new).unwrap().spread, d.item(new).unwrap().bounds()) } /// Paste and Paste in Place put the objects on the spread they are given (the first without one). #[test] fn paste_puts_the_objects_on_the_given_spread() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); let a = s.execute("frame.create", &json!({"rect": [36, 36, 136, 136]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [a]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 2})), (SpreadRef::Doc(2), Rect::new(36.0, 36.0, 136.0, 136.0))); assert_eq!(pasted(&mut s, "edit.paste", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(48.0, 48.0, 148.0, 148.0))); assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({})).0, SpreadRef::Doc(0)); // A spread the document doesn't have: an error, and nothing is pasted. let before = s.doc().unwrap().doc.clone(); assert!(s.execute("edit.paste", &json!({"spread": 9})).is_err()); assert_eq!(s.doc().unwrap().doc, before); before.check().unwrap(); } /// With facing pages the first page sits alone, right of the spine. An object keeps its side /// of the spine when it is pasted in place on another spread. #[test] fn paste_in_place_keeps_the_side_of_the_spine() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 4, "facingPages": true})).unwrap(); let w = s.doc().unwrap().doc.settings.page_width; // On page 3, the right-hand page of the second spread. let a = s.execute("frame.create", &json!({"spread": 1, "rect": [w + 36.0, 36, w + 136.0, 136]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [a]})).unwrap(); s.execute("edit.cut", &json!({})).unwrap(); assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 0})), (SpreadRef::Doc(0), Rect::new(36.0, 36.0, 136.0, 136.0))); // And back from page 1. s.execute("edit.cut", &json!({})).unwrap(); assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0))); // Page 4 sits alone left of the spine: the object stays right of it, off the page. assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 2})), (SpreadRef::Doc(2), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0))); } /// A single-sided page has no spine: an object keeps its distance from the page's left edge, /// whatever the page widths, and pastes as right-hand content into a facing-pages document. #[test] fn paste_in_place_from_a_single_sided_page_measures_from_its_left_edge() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); s.execute("layout.pageSize", &json!({"pages": [2], "width": 400, "height": 792})).unwrap(); let a = s.execute("frame.create", &json!({"spread": 1, "rect": [36, 36, 136, 136]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [a]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 0})), (SpreadRef::Doc(0), Rect::new(36.0, 36.0, 136.0, 136.0))); s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); let w = s.doc().unwrap().doc.settings.page_width; assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({})), (SpreadRef::Doc(0), Rect::new(36.0, 36.0, 136.0, 136.0))); assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0))); } /// A copied text frame's clipping holds the files its anchored graphics show. #[test] fn copied_text_frames_keep_the_graphics_anchored_in_their_text() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let png = designcraft_render::Rendered { width: 4, height: 4, pixels: vec![200u8; 64] }.to_png(); let placed = s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "x": 72, "y": 72})).unwrap(); let (g, aid) = (ItemId(placed["id"].as_u64().unwrap()), designcraft_doc::AssetId(placed["asset"].as_u64().unwrap())); let f = ItemId(s.execute("frame.create", &json!({"rect": [36, 200, 300, 400], "content": "text"})).unwrap()["id"].as_u64().unwrap()); s.edit(|d, _| { let it = d.item(g).cloned().ok_or(designcraft_doc::DocError::NoItem(g))?; let sid = d.item(f).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(designcraft_doc::DocError::NoItem(f))?; super::super::anchored::anchor_items(d, sid, 0, vec![it], &Default::default())?; Ok(()) }) .unwrap(); s.execute("selection.set", &json!({"ids": [f.0]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); let clip = s.clipboard.clone().unwrap(); assert_eq!(clip.assets.keys().copied().collect::>(), [aid]); } /// A selection can name an object that is gone (here the copy that Undo removed). Copy /// measures from the spread of the first selected object that is still there. #[test] fn copy_measures_from_the_first_selected_object_that_exists() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); let w = s.doc().unwrap().doc.settings.page_width; let a = s.execute("frame.create", &json!({"spread": 1, "rect": [w + 36.0, 36, w + 136.0, 136]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [a]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); let gone = s.execute("edit.paste", &json!({"spread": 1})).unwrap()["ids"][0].as_u64().unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.items, [ItemId(a)], "Undo restores the selection from before the paste"); s.doc_mut().unwrap().selection.items = vec![ItemId(gone), ItemId(a)]; s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0))); } } #[cfg(test)] mod preview_tests { use super::*; fn text_session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); s } fn size(s: &mut Session) -> f64 { s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64().unwrap() } fn preview_size(s: &mut Session, v: f64) { s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": v}}})).unwrap(); } #[test] fn previews_show_on_the_page_and_commit_as_one_undo_step() { let mut s = text_session(); let (before, undo) = (size(&mut s), s.doc().unwrap().history.undo.len()); for v in [20.0, 30.0, 40.0] { preview_size(&mut s, v); assert_eq!(size(&mut s), v, "each preview replaces the last"); } assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step while previewing"); s.execute("edit.commitPreview", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); assert!(s.doc().unwrap().interaction.is_none()); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(size(&mut s), before); } /// A preview whose command fails closes again, so the next edit is its own undo step. #[test] fn a_failed_preview_leaves_no_preview_open() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": 20}}})); assert!(r.is_err(), "nothing selected: type.char fails"); assert!(s.doc().unwrap().interaction.is_none()); let steps = s.doc().unwrap().history.undo.len(); s.execute("frame.create", &json!({"rect": [72, 72, 144, 144]})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); } #[test] fn a_cancelled_preview_leaves_the_document_as_it_was() { let mut s = text_session(); let (before, undo) = (size(&mut s), s.doc().unwrap().history.undo.len()); preview_size(&mut s, 50.0); s.execute("edit.cancelPreview", &json!({})).unwrap(); assert_eq!(size(&mut s), before); assert_eq!(s.doc().unwrap().history.undo.len(), undo); // Committing with nothing open does nothing. s.execute("edit.commitPreview", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } #[test] fn only_edits_are_previewed_and_another_interaction_is_left_alone() { let mut s = text_session(); for command in ["edit.preview", "edit.undo", "type.selectionAttrs", "no.such.command"] { assert!(s.execute("edit.preview", &json!({"label": "X", "command": command})).is_err(), "{command}"); } assert!(s.execute("edit.preview", &json!({"command": "type.char"})).is_err(), "needs a label"); s.begin_interaction("Move").unwrap(); assert!(s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": 20}}})).is_err()); assert_eq!(s.doc().unwrap().interaction.as_ref().unwrap().label, "Move"); } }