From 8e9c198c7c67b053ac30cde9c4976796f7ec173e Mon Sep 17 00:00:00 2001 From: Zetenypapp Date: Sun, 18 Oct 2026 17:47:04 +0200 Subject: [PATCH 1/2] Duplicate repeats the move of the last copy, as in PowerPoint (#966) Duplicating a shape, moving the copy and duplicating that copy again places the next copy the same distance further on. Anything else, an undone copy or another slide gets the usual offset. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 29 ++++++++++++++++++++++++----- crates/engine/src/lib.rs | 4 ++++ crates/engine/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 933bc7d1..80634851 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fdb30f1e..f00660f1 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -534,15 +534,31 @@ fn paste_text(s: &mut Session, p: &Value) -> Result { fn duplicate(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let off = s.prefs.duplicate_offset; - s.edit(|doc, sel| { + let last = s.doc()?.last_duplicate.clone(); + let (new_ids, made) = s.edit(|doc, sel| { let list = super::current_shapes(doc, sel).cloned().unwrap_or_default(); let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); + let sources: Vec = copies.iter().map(|c| c.id).collect(); let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + // Duplicating the copies of the last Duplicate again repeats how far they were moved + // since (#966); anything else gets the usual offset. + let (dx, dy) = last + .filter(|(slide, target, pairs)| { + *slide == sel.slide && *target == sel.target && pairs.len() == sources.len() && pairs.iter().all(|(_, c)| sources.contains(c)) + }) + .and_then(|(_, _, pairs)| { + let (src, copy) = pairs.first()?; + let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?); + let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?); + let d = (b.x - a.x, b.y - a.y); + (d.0.is_finite() && d.1.is_finite()).then_some(d) + }) + .unwrap_or((off, off)); reid("edit.duplicate", doc, &mut copies)?; for (c, b) in copies.iter_mut().zip(boxes) { let mut x = b; - x.x += off; - x.y += off; + x.x += dx; + x.y += dy; c.xfrm = Some(x); if sel.target == crate::Target::Slides { c.ph = None; @@ -555,8 +571,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?; shapes.extend(copies); sel.shapes = new_ids.clone(); - Ok(json!({"ids": new_ids})) - }) + let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect()); + Ok((new_ids, made)) + })?; + s.doc_mut()?.last_duplicate = Some(made); + Ok(json!({"ids": new_ids})) } fn delete(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 418bff04..54380bd1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -222,6 +222,9 @@ pub struct DocState { pub composition: Option, /// Review › Compare in progress: the revised copy. pub compare: Option, + /// The last Duplicate: where it happened and each original with its copy, so duplicating the + /// copies again repeats how far they were moved, as in PowerPoint (#966). + pub last_duplicate: Option<(usize, Target, Vec<(ShapeId, ShapeId)>)>, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -249,6 +252,7 @@ impl DocState { missing_fonts: Vec::new(), composition: None, compare: None, + last_duplicate: None, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd64b08c..fee47368 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4072,3 +4072,36 @@ fn a_shape_in_front_of_edited_text_takes_the_click() { let st = s.doc().unwrap(); assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); } + +#[test] +fn duplicating_a_moved_copy_repeats_the_move() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 50, 50]})).unwrap(); + let a = r["id"].as_u64().unwrap(); + let boxed = |s: &mut Session, id: u64| { + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + (v["box"]["x"].as_f64().unwrap(), v["box"]["y"].as_f64().unwrap()) + }; + let dup = |s: &mut Session| s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + // The first copy gets the usual offset. + let b = dup(&mut s); + let off = s.prefs.duplicate_offset; + assert_eq!(boxed(&mut s, b), (100.0 + off, 100.0 + off)); + // Moved, then duplicated again: the next copy repeats the move from the original (#966). + s.execute("shape.move", &json!({"dx": 80.0 - off, "dy": -off})).unwrap(); + assert_eq!(boxed(&mut s, b), (180.0, 100.0)); + let c = dup(&mut s); + assert_eq!(boxed(&mut s, c), (260.0, 100.0)); + let d = dup(&mut s); + assert_eq!(boxed(&mut s, d), (340.0, 100.0)); + // Duplicating something else goes back to the usual offset. + s.doc_mut().unwrap().selection.shapes = vec![ShapeId(a as u32)]; + let e = dup(&mut s); + assert_eq!(boxed(&mut s, e), (100.0 + off, 100.0 + off)); + // After Undo removed that copy, duplicating the last moved copy gets the usual offset too. + s.execute("edit.undo", &json!({})).unwrap(); + s.doc_mut().unwrap().selection.shapes = vec![ShapeId(d as u32)]; + let f = dup(&mut s); + assert_eq!(boxed(&mut s, f), (340.0 + off, 100.0 + off)); + assert_eq!(s.doc().unwrap().doc.validate(), Vec::::new()); +} From 86e7879f5eadce0907c7b173d0c8f304468e6a7e Mon Sep 17 00:00:00 2001 From: Zetenypapp Date: Sun, 18 Oct 2026 19:52:41 +0200 Subject: [PATCH 2/2] Duplicate: Undo and Redo forget the last Duplicate's move A shape added after an Undo can get an undone copy's id; duplicating it then used the move measured from the old original. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 2 ++ crates/engine/src/tests.rs | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f00660f1..139fa30e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -51,6 +51,8 @@ fn restore(s: &mut Session, redo: bool) -> Result { st.doc = e.doc; st.selection = e.selection; st.interaction = None; + // A later shape can get an undone copy's id: Duplicate starts over with the usual offset. + st.last_duplicate = None; st.revision += 1; Ok(json!({"label": e.label})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fee47368..c5cc3cd0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4098,6 +4098,13 @@ fn duplicating_a_moved_copy_repeats_the_move() { s.doc_mut().unwrap().selection.shapes = vec![ShapeId(a as u32)]; let e = dup(&mut s); assert_eq!(boxed(&mut s, e), (100.0 + off, 100.0 + off)); + // Undone, its id can go to a new shape: duplicating that one uses the usual offset too. + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [400, 300, 40, 40]})).unwrap(); + let g = r["id"].as_u64().unwrap(); + assert_eq!(g, e, "the new shape reuses the undone copy's id"); + let h = dup(&mut s); + assert_eq!(boxed(&mut s, h), (400.0 + off, 300.0 + off)); // After Undo removed that copy, duplicating the last moved copy gets the usual offset too. s.execute("edit.undo", &json!({})).unwrap(); s.doc_mut().unwrap().selection.shapes = vec![ShapeId(d as u32)];