diff --git a/ROADMAP.md b/ROADMAP.md index 8d54986c..d357164b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,7 @@ app's JSON control channel (`deckcraft --control PORT`). |---|---| | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) | +| 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-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | | 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cb552d65..fa13825f 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})) } @@ -534,15 +536,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 +573,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 996e3cb9..86f89103 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4142,3 +4142,43 @@ fn resizing_a_flipped_box_snaps_the_dragged_edge() { let b = box_of(&mut s, id); assert_eq!((b["x"].clone(), b["w"].clone()), (json!(0.0), json!(200.0)), "the dragged edge snaps to 0, the fixed edge stays at 200: {b}"); } + +#[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)); + // 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)]; + 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()); +}