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 <noreply@anthropic.com>
This commit is contained in:
noah1400
2026-10-10 19:08:43 +02:00
co-authored by Claude Opus 5.5
parent 92649ffdb0
commit dfdc650318
4 changed files with 161 additions and 7 deletions
+110 -6
View File
@@ -201,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
"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<CommandSpec> {
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<ItemId> = 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::<Vec<_>>()}))
})
@@ -254,13 +258,29 @@ pub fn specs() -> Vec<CommandSpec> {
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<designcraft_doc::Document> {
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)));
}
}
+4
View File
@@ -252,6 +252,9 @@ pub struct Session {
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
pub journal: Vec<(String, Value)>,
pub clipboard: Option<Arc<Document>>,
/// 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<std::path::PathBuf>,
/// 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![],
+7
View File
@@ -139,6 +139,13 @@ pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option<usize> {
.map(|(i, _)| i)
}
/// The spread under the view centre (a parent spread while parents are edited).
pub fn current_spread(app: &DesignApp) -> Option<SpreadRef> {
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<usize> {
let st = app.session.active()?;
+40 -1
View File
@@ -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<Value, String> {
pub fn run(&mut self, id: &str, mut params: Value) -> Result<Value, String> {
if let Some(r) = menus::run_ui(self, id, &params) {
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, &params).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.