mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 19:56:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
92649ffdb0
commit
dfdc650318
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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, ¶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.
|
||||
|
||||
Reference in New Issue
Block a user