diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs
--- a/crates/engine/src/commands/paths.rs
+++ b/crates/engine/src/commands/paths.rs
@@ -8,7 +8,7 @@ use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSourc
 use serde_json::{Value, json};
 
 use super::layer_menu::target_masks;
-use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p};
+use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p};
 use crate::{EngineError, Result, Session, VertexRef, cmd};
 
 // ---------------------------------------------------------------- RotoBezier
@@ -308,7 +308,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
         let y = v[1] * scale[1];
         [x * c - y * sn, x * sn + y * c]
     };
-    let n = s.edit("Free Transform Points", None, |proj, _| {
+    let n = s.edit("Free Transform Points", merge_p(p), |proj, _| {
         let mut n = 0;
         for (lid, uid) in &targets {
             let l = layer_mut(proj, cid, *lid)?;
@@ -541,8 +541,8 @@ pub fn specs() -> Vec<CommandSpec> {
             "path.freeTransform",
             "Free Transform Points",
             ["Layer", "Mask and Shape Path"],
-            None,
-            "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}",
+            None, 
+            "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}",
             has_layers,
             free_transform
         ),
diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs
--- a/crates/engine/src/tests_stubs.rs
+++ b/crates/engine/src/tests_stubs.rs
@@ -583,3 +583,22 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
     let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
     assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
 }
+
+/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
+/// shared merge key; one undo must revert the whole drag, not the last frame.
+#[test]
+fn free_transform_drag_undoes_in_one_step() {
+    let mut s = comp();
+    let (l, m) = masked(&mut s);
+    s.state.selected_vertices.clear();
+    let before = mask_path(&s, l, m);
+    for k in [90, 90, 90, 90] {
+        s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap();
+    }
+    let after = mask_path(&s, l, m);
+    assert_ne!(after, before);
+    s.execute("edit.undo", json!({})).unwrap();
+    assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag");
+    s.execute("edit.redo", json!({})).unwrap();
+    assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag");
+}
