mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 06:42:31 +00:00
Viewer: Alt-drag duplicates the dragged layers (#3076)
* Viewer: Alt-drag duplicates the dragged layers (#2968) Alt/Option-drag of the selected layers in the Composition viewer duplicates them and drags the copies; the copy and the drag are one undo step. edit.duplicate takes a merge key for it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Alt-drag: duplicate from a 3D gizmo arrow, no double move of a selected child Alt-dragging a gizmo arrow moved the original; it now duplicates and moves the copy along the axis. A selected layer whose parent is dragged too follows the parent, so its own Position no longer gets the drag a second time (with or without Alt). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb920d68ec
commit
9a32a51793
5 files changed
+214
-7
No files matched your search
@@ -6,7 +6,7 @@ use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, La
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p};
|
||||
use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, merge_p, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd};
|
||||
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -145,7 +145,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(layer) => layers_or(s, p, &[layer])?,
|
||||
None => layers_p(s, p)?,
|
||||
};
|
||||
let new = s.edit("Duplicate", None, |proj, st| {
|
||||
// `merge` folds the copy and the drag that follows it (Alt-drag in the viewer) into one undo step.
|
||||
let new = s.edit("Duplicate", merge_p(p), |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
let mut created = vec![];
|
||||
@@ -1007,7 +1008,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate",
|
||||
["Edit"],
|
||||
Some("Cmd+D"),
|
||||
"{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)",
|
||||
"{layers?, merge?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)",
|
||||
layers_or_text_edit,
|
||||
duplicate
|
||||
),
|
||||
|
||||
@@ -1090,6 +1090,45 @@ fn middle_pan(app: &mut EffectcraftApp, ui: &egui::Ui, resp: &egui::Response, v:
|
||||
true
|
||||
}
|
||||
|
||||
/// One layer of a Move: its id, start Position and the Position's per-comp-axis weights.
|
||||
type MoveLayer = (LayerId, [f64; 3], [f64; 3], [f64; 3]);
|
||||
|
||||
/// The merge key of the drag in progress: every step it writes folds into one undo step.
|
||||
fn drag_merge_key(ui: &egui::Ui) -> String {
|
||||
format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::unique("viewer-drag-n")).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Duplicate the layers of an Alt-drag (`edit.duplicate`, selecting the copies) and return the move
|
||||
/// entries re-pointed at the copies. If the duplicate fails the originals are dragged as they were.
|
||||
fn alt_duplicate(app: &mut EffectcraftApp, ui: &egui::Ui, layers: Vec<MoveLayer>) -> Vec<MoveLayer> {
|
||||
let ids: Vec<u64> = layers.iter().map(|l| l.0.0).collect();
|
||||
// A key left over from the previous drag would fold this copy into that drag's undo step.
|
||||
app.session.history.merge_key = None;
|
||||
let made = app.session.execute("edit.duplicate", json!({"layers": ids, "merge": drag_merge_key(ui)}));
|
||||
let Ok(copies) = made.map(|v| serde_json::from_value::<Vec<u64>>(v).unwrap_or_default()) else { return layers };
|
||||
layers.into_iter().zip(copies).map(|((_, p0, ax, ay), copy)| (LayerId(copy), p0, ax, ay)).collect()
|
||||
}
|
||||
|
||||
/// For each layer of a Move, whether one of its ancestors is in the Move as well: it follows that
|
||||
/// parent, so its own Position must not get the drag too. The copies `alt_duplicate` makes keep
|
||||
/// this order and are parented to each other the same way.
|
||||
fn led_by_a_moved_ancestor(comp: &Comp, layers: &[MoveLayer]) -> Vec<bool> {
|
||||
let moved: std::collections::HashSet<LayerId> = layers.iter().map(|l| l.0).collect();
|
||||
let led = |id: LayerId| {
|
||||
let mut up = comp.layer(id).and_then(|l| l.parent);
|
||||
// (bounded: a parent cycle in a broken file must not hang the viewer)
|
||||
for _ in 0..comp.layers.len() {
|
||||
let Some(p) = up else { return false };
|
||||
if moved.contains(&p) {
|
||||
return true;
|
||||
}
|
||||
up = comp.layer(p).and_then(|l| l.parent);
|
||||
}
|
||||
false
|
||||
};
|
||||
layers.iter().map(|l| led(l.0)).collect()
|
||||
}
|
||||
|
||||
fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
|
||||
let comp_rect = v.comp_rect;
|
||||
let zoom = v.zoom;
|
||||
@@ -1261,7 +1300,12 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx,
|
||||
// that axis only, without snapping.
|
||||
if let Some((lid, _, ax, ay)) = axis_at(&axis_hits, press) {
|
||||
let p0 = comp.layer(lid).and_then(|l| Some(ectx.v3(l, l.transform()?, "position", [0.0; 3])));
|
||||
p0.map(|p0| Gesture::Move { layers: vec![(lid, p0, ax, ay)], start: cpt, snap_src: None })
|
||||
p0.map(|p0| {
|
||||
// Alt duplicates the layer and moves the copy along the axis.
|
||||
let layers = vec![(lid, p0, ax, ay)];
|
||||
let layers = if mods.alt { alt_duplicate(app, ui, layers) } else { layers };
|
||||
Gesture::Move { layers, start: cpt, snap_src: None }
|
||||
})
|
||||
} else if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() {
|
||||
let layer = comp.layer(lid).cloned();
|
||||
layer.and_then(|layer| {
|
||||
@@ -1287,7 +1331,7 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx,
|
||||
})
|
||||
})
|
||||
} else if let Some(l) = pick(app, ectx, cpt, mods.shift) {
|
||||
let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
|
||||
let layers: Vec<MoveLayer> = app
|
||||
.session
|
||||
.state
|
||||
.selected_layers
|
||||
@@ -1309,6 +1353,13 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx,
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
});
|
||||
// A layer whose parent (or grandparent…) is dragged too rides along with it:
|
||||
// moving it as well would add the drag twice. It is still duplicated.
|
||||
let led = led_by_a_moved_ancestor(comp, &layers);
|
||||
// Alt-drag duplicates the dragged layers and moves the copies; the copy and the
|
||||
// drag are one undo step.
|
||||
let layers = if mods.alt && !layers.is_empty() { alt_duplicate(app, ui, layers) } else { layers };
|
||||
let layers: Vec<MoveLayer> = layers.into_iter().zip(led).filter(|(_, led)| !led).map(|(l, _)| l).collect();
|
||||
(!layers.is_empty()).then_some(Gesture::Move { layers, start: map.to_comp(press), snap_src })
|
||||
} else {
|
||||
// With path vertices on screen the marquee selects vertices (layers stay
|
||||
@@ -1386,7 +1437,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp:
|
||||
if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
|
||||
let map = edge_pan(app, &ctx, &g, map, pos);
|
||||
let cpt = map.to_comp(pos);
|
||||
let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::unique("viewer-drag-n")).unwrap_or(0)));
|
||||
let merge = drag_merge_key(ui);
|
||||
match g {
|
||||
Gesture::Pan { start_pan } => {
|
||||
// No total drag on the release frame: keep the pan reached so far (it used to
|
||||
|
||||
@@ -9,6 +9,7 @@ mod ui_3d;
|
||||
mod ui_accessibility;
|
||||
mod ui_accessibility_audit;
|
||||
mod ui_agent_audit;
|
||||
mod ui_alt_drag_duplicate;
|
||||
mod ui_angles;
|
||||
mod ui_audio_scrub;
|
||||
mod ui_azerty_shortcuts;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Alt/Option-drag in the Composition viewer (#2968) duplicates the dragged layers and moves the
|
||||
//! copies, copy and move together one undo step. A plain drag still moves the layer itself.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::panels::viewer::comp_to_screen;
|
||||
use egui::{Event, Modifiers, Pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// A 640×360 comp with two solids, A (left) and B (right), both selected.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "A", "color": "#e04020", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "B", "color": "#2040e0", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [150.0, 180.0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [450.0, 180.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(4);
|
||||
(h, a, b)
|
||||
}
|
||||
|
||||
fn at(h: &Harness<'_, EffectcraftApp>, comp: [f32; 2]) -> Pos2 {
|
||||
comp_to_screen(&h.ctx, comp).unwrap()
|
||||
}
|
||||
|
||||
fn zoom(h: &Harness<'_, EffectcraftApp>) -> f32 {
|
||||
(at(h, [100.0, 0.0]).x - at(h, [0.0, 0.0]).x) / 100.0
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
for i in 1..=12 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 12.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default()));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn pos(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> [f64; 2] {
|
||||
let v = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().value.as_vec2();
|
||||
[v[0], v[1]]
|
||||
}
|
||||
|
||||
fn count(h: &Harness<'_, EffectcraftApp>) -> usize {
|
||||
h.state().session.active_comp().unwrap().layers.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_duplicates_the_selected_layers_and_moves_the_copies() {
|
||||
let (mut h, a, b) = harness();
|
||||
let undo = h.state().session.history.undo.len();
|
||||
let z = zoom(&h);
|
||||
let from = at(&h, [150.0, 180.0]);
|
||||
drag(&mut h, from, from + vec2(0.0, 60.0) * z, Modifiers::ALT);
|
||||
assert_eq!(count(&h), 4, "both selected layers were copied");
|
||||
assert_eq!(pos(&h, LayerId(a)), [150.0, 180.0], "the originals stay");
|
||||
assert_eq!(pos(&h, LayerId(b)), [450.0, 180.0]);
|
||||
let sel = h.state().session.state.selected_layers.clone();
|
||||
assert_eq!(sel.len(), 2, "the copies are selected");
|
||||
let mut moved: Vec<[f64; 2]> = sel.iter().map(|l| pos(&h, *l)).collect();
|
||||
moved.sort_by(|p, q| p[0].total_cmp(&q[0]));
|
||||
for (got, want) in moved.iter().zip([[150.0, 240.0], [450.0, 240.0]]) {
|
||||
assert!((got[0] - want[0]).abs() < 1.5 && (got[1] - want[1]).abs() < 1.5, "copy at {got:?}, wanted {want:?}");
|
||||
}
|
||||
assert_eq!(h.state().session.history.undo.len(), undo + 1, "copy and drag are one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(count(&h), 2, "one undo removes the copies");
|
||||
assert_eq!(pos(&h, LayerId(a)), [150.0, 180.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_drag_moves_the_layers_without_copying() {
|
||||
let (mut h, a, _) = harness();
|
||||
let z = zoom(&h);
|
||||
let from = at(&h, [150.0, 180.0]);
|
||||
drag(&mut h, from, from + vec2(0.0, 60.0) * z, Modifiers::NONE);
|
||||
assert_eq!(count(&h), 2);
|
||||
let p = pos(&h, LayerId(a));
|
||||
assert!((p[1] - 240.0).abs() < 1.5, "the layer moved: {p:?}");
|
||||
}
|
||||
|
||||
/// A second Alt-drag right after the first is its own undo step: its copy doesn't fold into the
|
||||
/// first drag's step.
|
||||
#[test]
|
||||
fn a_second_alt_drag_is_its_own_undo_step() {
|
||||
let (mut h, _, _) = harness();
|
||||
let z = zoom(&h);
|
||||
let from = at(&h, [150.0, 180.0]);
|
||||
drag(&mut h, from, from + vec2(0.0, 60.0) * z, Modifiers::ALT);
|
||||
let undo = h.state().session.history.undo.len();
|
||||
let from = at(&h, [150.0, 240.0]);
|
||||
drag(&mut h, from, from + vec2(0.0, 40.0) * z, Modifiers::ALT);
|
||||
assert_eq!(count(&h), 6);
|
||||
assert_eq!(h.state().session.history.undo.len(), undo + 1);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(count(&h), 4, "one undo removes only the second copies");
|
||||
}
|
||||
|
||||
/// A selected child under a selected parent follows the parent: its own Position doesn't get the
|
||||
/// drag a second time, with or without Alt (the child's copy is parented to the parent's copy).
|
||||
#[test]
|
||||
fn a_selected_child_keeps_its_place_under_its_dragged_parent() {
|
||||
for modifiers in [Modifiers::NONE, Modifiers::ALT] {
|
||||
let (mut h, a, b) = harness();
|
||||
h.state_mut().session.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let local = pos(&h, LayerId(b));
|
||||
let z = zoom(&h);
|
||||
let from = at(&h, [150.0, 180.0]);
|
||||
drag(&mut h, from, from + vec2(0.0, 60.0) * z, modifiers);
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
let sel = h.state().session.state.selected_layers.clone();
|
||||
let parent = *sel.iter().find(|l| comp.layer(**l).unwrap().parent.is_none()).unwrap();
|
||||
let child = *sel.iter().find(|l| comp.layer(**l).unwrap().parent == Some(parent)).expect("the child follows the moved parent");
|
||||
let p = pos(&h, parent);
|
||||
assert!((p[1] - 240.0).abs() < 1.5, "{modifiers:?}: the parent moved: {p:?}");
|
||||
let c = pos(&h, child);
|
||||
assert!((c[0] - local[0]).abs() < 1e-6 && (c[1] - local[1]).abs() < 1e-6, "{modifiers:?}: child at {c:?}, was {local:?} under its parent");
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt-dragging a 3D layer's gizmo arrow copies the layer and moves the copy along that axis.
|
||||
#[test]
|
||||
fn alt_dragging_a_gizmo_arrow_moves_a_copy() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [solid]})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(6);
|
||||
let p0 = pos(&h, LayerId(solid));
|
||||
let [x, y, w, hh] = h.state().auto.find(&format!("viewer.gizmo.{solid}.x")).expect("x arrow registered").rect;
|
||||
let tip = Pos2::new(x + w / 2.0, y + hh / 2.0);
|
||||
drag(&mut h, tip, tip + vec2(40.0, 25.0), Modifiers::ALT);
|
||||
assert_eq!(count(&h), 2, "the layer was copied");
|
||||
assert_eq!(pos(&h, LayerId(solid)), p0, "the original stays");
|
||||
let copy = h.state().session.state.selected_layers[0];
|
||||
assert_ne!(copy, LayerId(solid), "the copy is selected");
|
||||
let p = pos(&h, copy);
|
||||
assert!(p[0] > p0[0] + 10.0 && p[1] == p0[1], "the copy moved along x: {p0:?} -> {p:?}");
|
||||
}
|
||||
+1
-1
@@ -20,7 +20,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|
||||
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it |
|
||||
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
|
||||
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |
|
||||
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed |
|
||||
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #2968 Alt-drag in the viewer duplicates the dragged layers; #491 (Linux multi-layer edits) is closed |
|
||||
| Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) |
|
||||
| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels: labels exist (a colour bar on the effect header and on the effect's Timeline row, right-click Label menu, `effect.setLabel`), the rebuilt controls are not matched |
|
||||
| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`, `ui_project_rows`; #227, #290, #407 fixed; a comp's Project row menu has Composition Settings… (#1058); layer context menu has Layer Styles (#1611) |
|
||||
|
||||
Reference in new issue
Block a user