Browse / github.com/storytold/figcraft / patch
M1: Alt pressed during a drag turns the move into a copy (#1385)
Feature Request: Allow Alt/Option + Drag Duplicate When Modifier Is Pressed Mid-Drag # Feature Request: Support drag-to-duplicate when Alt/Option is pressed mid-drag ## Summary Currently, holding **Alt** (Windows/Linux) or **Option** (macOS) to duplicate via drag only works if the modifier key is held **before** the drag begins. If the user starts dragging first and then presses the modifier, the element simply moves instead of duplicating. Figma and Adobe XD both support pressing the modifier **at any point** during the drag — before or after it starts. This request is to match that behavior. ## Current Behavior - Hold Alt/Option → start drag → duplicate ✅ - Start drag →
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- muamerETS (imported from pull request #1427, open)
- Base
43af145a12eacd1e87f5f5e6984c49b7da040288- Size
- +90 −2 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
3ef931c8482150c3acef1c97
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/canvas/snap.rs crates/engine/src/canvas/tools/drag.rs crates/engine/src/canvas/tools/draw.rs crates/engine/src/duplicate_spacing_tests.rs docs/roadmap.md
@@ -138,6 +138,24 @@ impl Targets { self.boxes.push(r); } + /// Take back what [`Targets::add_box`] added for `r`.+ pub fn remove_box(&mut self, r: Rect) {+ let take = |v: &mut Vec<Span>, s: Span| {+ if let Some(i) = v.iter().rposition(|x| *x == s) {+ v.remove(i);+ }+ };+ for x in [r.x0, r.center().x, r.x1] {+ take(&mut self.xs, (x, r.y0, r.y1));+ }+ for y in [r.y0, r.center().y, r.y1] {+ take(&mut self.ys, (y, r.x0, r.x1));+ }+ if let Some(i) = self.boxes.iter().rposition(|b| *b == r) {+ self.boxes.remove(i);+ }+ }+ /// 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) {@@ -159,7 +159,20 @@ impl Session { } pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> {- let g = std::mem::take(&mut self.tool.gesture);+ let mut g = std::mem::take(&mut self.tool.gesture);+ // Alt makes a move a copy whenever it goes down, before the drag or during it, and letting+ // go of it moves the original again; a copy snaps to the layers it came from too.+ if let Gesture::Move { targets, duplicate, .. } = &mut g+ && *duplicate != ev.alt+ {+ *duplicate = ev.alt;+ if let Some(b) = targets.moving {+ if ev.alt { targets.add_box(b) } else { targets.remove_box(b) }+ }+ if let Some(it) = self.ds_mut().ok().and_then(|ds| ds.interaction.as_mut()) {+ it.label = if ev.alt { "Duplicate" } else { "Move" }.into();+ }+ } let res = match &g { Gesture::None => return self.hover(ev), Gesture::Marquee { start, additive, base, within } => {@@ -98,6 +98,10 @@ impl Session { } pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> {+ // Alt went down or up since the last move: the drop is a copy or a move as the release has it.+ if matches!(&self.tool.gesture, Gesture::Move { moved: true, duplicate, .. } if *duplicate != ev.alt) {+ self.drag(ev)?;+ } let g = std::mem::take(&mut self.tool.gesture); self.tool.overlay.marquee = None; self.tool.overlay.guides.clear();@@ -169,6 +169,58 @@ fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) { s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap(); } +/// Drag from `from` to `to` in four steps, Alt held at step `k` when `alt(k)` (0: the press, 1–4:+/// the moves, 5: the release).+fn drag_alt_when(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: impl Fn(u8) -> bool) {+ let ev = |k: u8, kind, (x, y): (f64, f64)| PointerEvent { alt: alt(k), ..PointerEvent::at(kind, x, y, 1.0) };+ s.pointer(&ev(0, PointerKind::Down, from)).unwrap();+ for k in 1..=4 {+ let t = f64::from(k) / 4.0;+ s.pointer(&ev(k, PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap();+ }+ s.pointer(&ev(5, PointerKind::Up, to)).unwrap();+}++fn layers(s: &Session) -> usize {+ s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()+}++fn last_step(s: &Session) -> String {+ s.ds().unwrap().history.undo.last().map(|e| e.label.clone()).unwrap_or_default()+}++#[test]+fn alt_pressed_during_a_drag_turns_the_move_into_a_copy() {+ // #1385: as in other design apps, Alt counts whenever it goes down, not only at the press.+ let mut s = Session::new();+ let a = rect(&mut s, 0.0, 0.0);+ // Alt from the third step: the original goes back, a copy lands where the drag ends and snaps+ // to the original (2 px below its top edge).+ drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k >= 3);+ let copy = s.selection()[0];+ assert_ne!(copy, a);+ assert_eq!((at(&s, a), at(&s, copy)), ((0.0, 0.0), (200.0, 0.0)));+ assert_eq!((layers(&s), last_step(&s).as_str()), (2, "Duplicate"));+ assert!(s.undo().unwrap());+ assert_eq!((layers(&s), at(&s, a)), (1, (0.0, 0.0)), "one undo step");+ // Let go of Alt during the drag: no copy, the original moves (and no longer snaps to where it+ // started, as only a copy does).+ drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 3);+ assert_eq!((layers(&s), s.selection(), at(&s, a)), (1, vec![a], (200.0, 2.0)));+ assert_eq!(last_step(&s), "Move");+ // Down and up again during one drag: a copy, as Alt is held at the end.+ s.undo().unwrap();+ drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 1 || k >= 4);+ assert_eq!((layers(&s), at(&s, a)), (2, (0.0, 0.0)));+ // Alt changed only by the release (no move in between): the release decides.+ s.undo().unwrap();+ drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 5);+ assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (2, (0.0, 0.0), "Duplicate"), "Alt down at the release: a copy");+ s.undo().unwrap();+ drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 5);+ assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (1, (200.0, 2.0), "Move"), "Alt up at the release: a move");+}+ /// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests /// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done, /// and the step is measured from where the copy ended up, whichever way it got there.@@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Alt pressed during a drag duplicates (#1385); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours@@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Alt during a drag (M1, #1385): Alt (Option) turns a move into a copy whenever it goes down, before the drag or during it, and letting go of it during the drag moves the original again; the undo step is named for what the drag did, and only a copy snaps to the layers it came from. Regression test in the duplicate spacing tests. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |