diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb0..df6afc6d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "edit.duplicate", "Duplicate", - "{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}", + "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}", true, duplicate ), @@ -412,13 +412,48 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": ids})) } +/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the +/// copies are the selection, Duplicate places the next copies as far from them as they are from +/// their sources, measured then: so moving the copies first sets the step. +#[derive(Clone, Debug)] +pub struct Duplicated { + pub sources: Vec, + pub copies: Vec, +} + +impl Duplicated { + pub fn is_selection(&self, sel: &[NodeId]) -> bool { + let set = |v: &[NodeId]| v.iter().copied().collect::>(); + !self.copies.is_empty() && set(&self.copies) == set(sel) + } + + /// The page offset from the sources to the copies, if both are still there and the copies move + /// freely (an auto layout places its own). + fn offset(&self, doc: &Document) -> Option<(f64, f64)> { + let free = self + .copies + .iter() + .all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none()))); + if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) { + return None; + } + let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?); + let d = (b.x0 - a.x0, b.y0 - a.y0); + (d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d) + } +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.duplicate", s, p)?; let rename = s.prefs.rename_duplicates; + let explicit = num(p, "dx").is_some() || num(p, "dy").is_some(); + let ds = s.ds()?; + let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids)); + let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc)); let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); + let ids = roots_now; let mut out = Vec::new(); - for i in ids { + for i in ids.iter().copied() { let n = doc.get(i)?; let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?; let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like(); @@ -432,14 +467,21 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { _ if top_frame => (w + 100.0, 0.0), _ => (0.0, 0.0), }; - let nn = doc.get_mut(nid)?; - nn.transform = Affine::translate((dx, dy)) * nn.transform; + match repeat { + Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?, + None => { + let nn = doc.get_mut(nid)?; + nn.transform = Affine::translate((dx, dy)) * nn.transform; + } + } if rename { + let nn = doc.get_mut(nid)?; nn.name = bump_name(&nn.name); } out.push(nid); } s.set_selection(out.clone()); + s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() }); Ok(json!({"ids": out})) } diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs new file mode 100644 index 00000000..b65fbb64 --- /dev/null +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -0,0 +1,159 @@ +//! Duplicating at a spacing: a copy Alt-dragged off a layer snaps to that layer too (its edges, +//! centres and equal spacing), and Duplicate repeats the last duplicate's offset while its copies +//! are selected, so Alt-drag (or Duplicate and move) then Cmd+D steps out evenly spaced copies. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100}))["id"]).unwrap() +} + +/// Alt-drag from `from` to `to`: returns the copy (the selection afterwards). +fn alt_drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId { + let ev = |kind, (x, y): (f64, f64)| PointerEvent { alt: true, ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&ev(PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap(); + } + s.pointer(&ev(PointerKind::Up, to)).unwrap(); + let sel = s.selection(); + assert_eq!(sel.len(), 1, "one copy selected"); + sel[0] +} + +/// Duplicate the selection: the copy. +fn cmd_d(s: &mut Session) -> NodeId { + let out = ex(s, "edit.duplicate", json!({})); + id_of(&out["ids"][0]).unwrap() +} + +fn at(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} + +#[test] +fn an_alt_dragged_copy_snaps_to_the_layer_it_came_from() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + // 2 px below the original's top edge: within the snap distance. + let copy = alt_drag(&mut s, (50.0, 50.0), (250.0, 52.0)); + assert_ne!(copy, a); + assert_eq!(at(&s, copy), (200.0, 0.0), "the copy's top lines up with the original's"); + assert_eq!(at(&s, a), (0.0, 0.0), "the original stays"); +} + +#[test] +fn an_alt_dragged_copy_spaces_equally_with_the_layer_it_came_from() { + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0); + rect(&mut s, 200.0, 0.0); + // B's copy dropped 2 px short of the 100 gap that A and B have. + let copy = alt_drag(&mut s, (250.0, 50.0), (448.0, 50.0)); + assert_eq!(at(&s, copy), (400.0, 0.0)); +} + +#[test] +fn duplicate_repeats_the_step_set_by_moving_the_copy() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place"); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150, "y": 20}})); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again"); + let c3 = cmd_d(&mut s); + assert_eq!(at(&s, c3), (450.0, 60.0), "and again"); +} + +#[test] +fn duplicate_after_an_alt_drag_repeats_its_step() { + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0); + let copy = alt_drag(&mut s, (50.0, 50.0), (180.0, 50.0)); + assert_eq!(at(&s, copy), (130.0, 0.0)); + let next = cmd_d(&mut s); + assert_eq!(at(&s, next), (260.0, 0.0)); +} + +#[test] +fn duplicate_only_repeats_for_the_last_copies() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + // Another selection forgets it: the original duplicates in place. + ex(&mut s, "select.set", json!({"ids": [a]})); + ex(&mut s, "select.set", json!({"ids": [c1]})); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (150.0, 0.0), "the step was forgotten when the selection changed"); + // An offset given explicitly wins over a remembered one. + ex(&mut s, "node.set", json!({"ids": [c2], "props": {"x": 300}})); + let out = ex(&mut s, "edit.duplicate", json!({"dx": 10})); + let c3 = id_of(&out["ids"][0]).unwrap(); + assert_eq!(at(&s, c3), (310.0, 0.0)); +} + +#[test] +fn undo_doesnt_let_a_new_layer_take_a_remembered_step() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "edit.undo", json!({})); + assert!(!s.doc().unwrap().contains(c1)); + // Undo gives ids back: the new rectangle may well get the undone copy's. + let r = rect(&mut s, 500.0, 500.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + let c = cmd_d(&mut s); + assert_eq!(at(&s, c), (500.0, 500.0), "a plain duplicate, in place"); +} + +#[test] +fn the_step_is_measured_in_page_space_inside_a_rotated_frame() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 600, "height": 600}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [f], "props": {"rotation": 30}})); + let a = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": f}))["id"]).unwrap(); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + let (p0, p1) = (at(&s, a), at(&s, c1)); + let c2 = cmd_d(&mut s); + let p2 = at(&s, c2); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + assert!(close(p2.0 - p1.0, p1.0 - p0.0) && close(p2.1 - p1.1, p1.1 - p0.1), "{p0:?} {p1:?} {p2:?}"); +} + +#[test] +fn an_alt_drag_into_auto_layout_sets_no_step() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap(); + for i in 0..2 { + ex(&mut s, "shape.rectangle", json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f})); + } + ex(&mut s, "layout.add", json!({"ids": [f]})); + ex(&mut s, "select.none", json!({})); + let kids = s.doc().unwrap().node(f).unwrap().children.clone(); + let b = s.doc().unwrap().world_bounds(kids[0]).unwrap(); + let from = (b.center().x, b.center().y); + alt_drag(&mut s, from, (from.0 + 70.0, from.1)); + assert!(s.ds().unwrap().last_duplicate.is_none(), "the layout placed the copy: no step to repeat"); + let n = s.doc().unwrap().node(f).unwrap().children.len(); + cmd_d(&mut s); + assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..f0e5968d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -328,6 +328,9 @@ pub struct DocState { pub guide: Option<(NodeId, usize)>, /// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved). pub(crate) rotation_origin: Option, + /// The last duplicate while its copies are the selection: Duplicate repeats its offset (see + /// [`cmd::edit::Duplicated`]; not saved). + pub(crate) last_duplicate: Option, } /// A version shown on the canvas in place of the document: read-only until it's restored or the @@ -383,6 +386,7 @@ impl DocState { focus: None, guide: None, rotation_origin: None, + last_duplicate: None, }; st.zoom_to_fit(); st @@ -538,6 +542,10 @@ impl DocState { if self.rotation_origin.as_ref().is_some_and(|o| o.ids != self.selection) { self.rotation_origin = None; } + // So does the last duplicate (undo reuses ids: a new layer could take a copy's). + if self.last_duplicate.as_ref().is_some_and(|d| !d.is_selection(&self.selection)) { + self.last_duplicate = None; + } } } @@ -1441,6 +1449,8 @@ mod double_click_tests; #[cfg(test)] mod draw_tools_tests; #[cfg(test)] +mod duplicate_spacing_tests; +#[cfg(test)] mod effects_tests; #[cfg(test)] mod export_behaviour_tests; diff --git a/crates/engine/src/measure_tests.rs b/crates/engine/src/measure_tests.rs index 2329e7be..539386bd 100644 --- a/crates/engine/src/measure_tests.rs +++ b/crates/engine/src/measure_tests.rs @@ -196,16 +196,17 @@ fn alt_drag_shows_live_distances_to_the_neighbours() { e }; s.pointer(&ev(PointerKind::Down, 30.0)).unwrap(); - // Alt duplicates: the copy moves to 110..160, between the original and the other layer. + // Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original + // and the other layer (105..155). s.pointer(&ev(PointerKind::Move, 130.0)).unwrap(); let g = s.tool.overlay.guides.clone(); assert_eq!( labelled(&g), vec![ - ("30".into(), [135, 0, 135, 30]), - ("30".into(), [135, 70, 135, 100]), - ("40".into(), [160, 50, 200, 50]), - ("50".into(), [60, 50, 110, 50]) + ("30".into(), [130, 0, 130, 30]), + ("30".into(), [130, 70, 130, 100]), + ("45".into(), [60, 50, 105, 50]), + ("45".into(), [155, 50, 200, 50]) ] ); s.pointer(&ev(PointerKind::Up, 130.0)).unwrap(); diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 82474df0..b68f1fe9 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -74,15 +74,6 @@ impl Targets { // A layer's box in snap space. let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite()); let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() }; - let push = |r: Rect, t: &mut Targets| { - for x in [r.x0, r.center().x, r.x1] { - t.xs.push((x, r.y0, r.y1)); - } - for y in [r.y0, r.center().y, r.y1] { - t.ys.push((y, r.x0, r.x1)); - } - t.boxes.push(r); - }; let parent = parent_of(doc, page, moving); // The parent frame's own box (and its layout grids). if parent != page @@ -109,7 +100,7 @@ impl Targets { continue; } if let Some(b) = bbox(*c) { - push(b, &mut t); + t.add_box(b); } } } @@ -136,6 +127,17 @@ impl Targets { t } + /// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it. + pub fn add_box(&mut self, r: Rect) { + for x in [r.x0, r.center().x, r.x1] { + self.xs.push((x, r.y0, r.y1)); + } + for y in [r.y0, r.center().y, r.y1] { + self.ys.push((y, r.x0, r.x1)); + } + self.boxes.push(r); + } + /// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes, /// and a marker when a moving point lands on it. pub fn add_point(&mut self, p: Point) { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d..ff836b28 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1164,7 +1164,13 @@ impl Session { if ids.is_empty() { return Ok(()); } - let targets = Targets::gather(&doc, page, &ids); + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; @@ -1675,6 +1681,7 @@ impl Session { // dropped; layers in an auto layout frame already sit in their slot. let page = self.page()?; let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; let moving = if duplicate { sel } else { ids }; let doc = self.doc()?.clone(); let in_al = @@ -1703,6 +1710,10 @@ impl Session { self.cancel_interaction()?; } else { self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } } } else { self.cancel_interaction()?; diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..4d193ec8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1406,6 +1406,12 @@ turned off 0°. With `pointer`, `alt: true` hovers measure (add `cmd` for nested layers, `shift` for padding) and `alt` drags show live distances to the neighbours; `inspect_ui` reports them under `tool.overlay` (`guides`, and `measure_box` for the box measured to). +- **Duplicating at a spacing**: a copy Alt-dragged off a layer with `pointer` snaps to that layer + too (its edges, centres and equal spacing). `edit.duplicate {ids?, dx?, dy?}` without `dx`/`dy` + repeats the last duplicate's page offset while its copies are what it duplicates (and still the + selection), measured when it runs: Alt-drag or duplicate and move a copy, then each + `edit.duplicate` steps out the next one as far again. Copies placed by an auto layout set no + step; explicit `dx`/`dy` always win. - **Scale tool** (`tool.scale`, K): dragging a selection handle with `pointer` scales everything by one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag, diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7..ee6cf314 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -55,7 +55,7 @@ interaction feel not yet compared. | Flip, rotate 90° | done | | | Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) | | Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first | -| Alt-drag duplicate | done | | +| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) | | Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) | | Scale tool (K): handles about an anchor, live factor, panel | done | | | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |