From dfdc650318fbd48d6ce75b4e4ec3f1b233ac0f51 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:50:29 +0200 Subject: [PATCH] Paste: copied objects land on the spread in view (#348) Paste and Paste in Place always put the objects on the first spread, whatever page was showing. They now go to the spread in view, or to the one a caller names with `spread`. With facing pages the objects keep their distance from the spine, so they stay on their side of the spread, also on a first or last page that sits alone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 116 ++++++++++++++++++++++++++++++++-- crates/engine/src/lib.rs | 4 ++ crates/ui-egui/src/canvas.rs | 7 ++ crates/ui-egui/src/lib.rs | 41 +++++++++++- 4 files changed, 161 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190acb4..f1d21f864 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -201,7 +201,7 @@ pub fn specs() -> Vec { if let Some(text) = copy_text(s)? { return Ok(json!({"text": text})); } - s.clipboard = Some(Arc::new(clip_doc(s)?)); + copy_items(s)?; s.text_clipboard = None; ok() }), @@ -210,7 +210,7 @@ pub fn specs() -> Vec { super::text::delete_selection(s)?; return Ok(json!({"text": text})); } - s.clipboard = Some(Arc::new(clip_doc(s)?)); + copy_items(s)?; s.execute("edit.clear", p) }), cmd!( @@ -227,7 +227,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}", + "{inPlace?: bool, spread?: index (0), text?: the system clipboard's text (pastes it unless it is what was copied here)}", has_clip_or_text, |s, p| { if s.doc()?.selection.text.is_some() { @@ -237,9 +237,13 @@ pub fn specs() -> Vec { 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, "spread"); + 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| { - let new = super::object::duplicate_from(d, &clip, &ids, SpreadRef::Doc(0), designcraft_geom::Vec2::new(off, off))?; + // 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::>()})) }) @@ -254,13 +258,29 @@ pub fn specs() -> Vec { has_clip, paste_into ), - cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clip, |s, _| s - .execute("edit.paste", &json!({"inPlace": true}))), + 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}))), ] } +/// 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() } +} + /// 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()?; @@ -455,3 +475,87 @@ mod paste_into_tests { s.doc().unwrap().doc.check().unwrap(); } } + +#[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))); + } + + /// Undo leaves the id of the copy it removed in the selection. 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(); + s.execute("selection.set", &json!({"ids": [a], "add": true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.items, [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))); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 771c89b13..5fc705f48 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -252,6 +252,9 @@ pub struct Session { pub(crate) tool: Box, pub journal: Vec<(String, Value)>, pub clipboard: Option>, + /// The x the clipboard's objects are measured from on their spread: its spine, or the left edge of + /// single-sided pages. Paste keeps their distance from it. + pub clipboard_origin: f64, /// Crash-recovery folder (the app sets it; saving or closing a document removes its entry). pub recovery_dir: Option, /// Copied text with its formatting (a frameless story slice) and its plain text. @@ -292,6 +295,7 @@ impl Session { tool: designcraft_tools::create("selection"), journal: vec![], clipboard: None, + clipboard_origin: 0.0, recovery_dir: None, text_clipboard: None, ui_requests: vec![], diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd61442..014e80a34 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -139,6 +139,13 @@ pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option { .map(|(i, _)| i) } +/// The spread under the view centre (a parent spread while parents are edited). +pub fn current_spread(app: &DesignApp) -> Option { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + Some(layout.slots.get(current_slot(app, &layout)?)?.spread) +} + /// Absolute page index under the view centre. pub fn current_page(app: &DesignApp) -> Option { let st = app.session.active()?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..d3df1da93 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -560,10 +560,17 @@ impl DesignApp { } /// THE entry point for every action (menus, shortcuts, palette, panels, control channel). - pub fn run(&mut self, id: &str, params: Value) -> Result { + pub fn run(&mut self, id: &str, mut params: Value) -> Result { if let Some(r) = menus::run_ui(self, id, ¶ms) { return r; } + // Copied objects paste on the spread in view. + if matches!(id, "edit.paste" | "edit.pasteInPlace") + && let Some(spread) = canvas::current_spread(self) + && let Some(p) = params.as_object_mut() + { + p.entry("spread").or_insert(json!(spread)); + } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.after_engine(); if let Err(e) = &r { @@ -1278,6 +1285,38 @@ mod tests { assert_eq!(r["ok"], json!(false), "{r}"); assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert"); } + + /// Copied objects were pasted on the first spread whatever page was in view. + #[test] + fn paste_lands_on_the_spread_in_view() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"pages": 3, "facingPages": false})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let pasted_on = |app: &DesignApp| { + let st = app.session.active().unwrap(); + st.doc.find(st.selection.items[0]).unwrap().spread + }; + // Nothing is in view before the canvas is laid out: the first spread, as in a script. + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(pasted_on(&app), designcraft_doc::SpreadRef::Doc(0)); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + canvas::go_to_page(&mut app, 2); + menus::activate(&mut app, "edit.pasteInPlace", &Value::Null); + assert_eq!(pasted_on(&app), designcraft_doc::SpreadRef::Doc(2)); + canvas::go_to_page(&mut app, 1); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(pasted_on(&app), designcraft_doc::SpreadRef::Doc(1)); + // A parent spread while parents are edited. + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(pasted_on(&app), designcraft_doc::SpreadRef::Parent(0)); + // With an insertion point Paste still goes into the text, where the object is anchored. + let story = app.run("frame.create", json!({"rect": [36, 200, 300, 400], "content": "text", "text": "ab"})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": story, "anchor": 2})).unwrap(); + assert_eq!(app.run("edit.paste", json!({})).unwrap()["anchored"], 1); + } } /// The whole window in a headless test harness, at a chosen window size.