Merge pull request #988 from Zetenypapp/fix/smart-duplicate-966

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-23 17:09:38 -04:00
4 changed files with 71 additions and 5 deletions
+1
View File
@@ -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) |
+26 -5
View File
@@ -51,6 +51,8 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> {
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<Value> {
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
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<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let sources: Vec<ShapeId> = 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<Value> {
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<Value> {
+4
View File
@@ -222,6 +222,9 @@ pub struct DocState {
pub composition: Option<Composition>,
/// Review › Compare in progress: the revised copy.
pub compare: Option<compare::Compare>,
/// 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 {
+40
View File
@@ -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::<String>::new());
}