M5.11: Graph Editor: drags move every selected key, Shift keeps an axis, Ctrl / Ctrl+Alt clicks; drags and the transform box never merge or lose keys

- `keys.transform {fromStart}`: each step of a merged drag gives the whole transform since the
  drag started and applies to the keys as they were then. Keys squeezed onto one frame on the
  way (Alt-drag scaling, the transform box) are no longer merged for good, and a slow
  transform-box move, whose per-frame offsets rounded to nothing, now moves the keys.
- Dragging a key in the Graph Editor moves every selected key in time and value (it moved only
  the grabbed one), by how far the pointer went from where the button went down (the key no
  longer jumps under the pointer); Shift keeps the drag to the time or the value axis.
- Shift+click toggles a key in or out of the selection; Ctrl+click switches Linear ↔ Auto
  Bezier and Ctrl+Alt+click toggles Hold, as in the Timeline.
- The transform box and the Timeline's Alt-drag scale send whole-gesture values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 07:52:20 +07:00
co-authored by Claude Opus 5.5
parent 9929b5f9ac
commit daed03305a
5 changed files with 130 additions and 33 deletions
+21 -4
View File
@@ -19,11 +19,25 @@ fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
/// With `fromStart` (drags), each step of one merged gesture gives the whole transform since the
/// drag started and applies to the keys as they were then: keys squeezed onto one frame on the
/// way, or rounded steps, are never lost; only the final position counts.
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let merge = merge_p(p);
let from_start = b_p(p, "fromStart").unwrap_or(false) && merge.is_some();
let gesture = from_start && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
let (sel, base) = match (&s.key_move, s.history.undo.last()) {
(Some(g), Some((_, b))) if gesture => (g.from.clone(), Some(b.clone())),
_ => (s.state.selected_keys.clone(), None),
};
if from_start {
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: sel.clone(), total: Tick::ZERO });
}
// The comp as it was when the drag started (its keys are what the transform applies to).
let start = base.clone().unwrap_or_else(|| s.project.clone());
let comp = start.comp(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let sel = s.state.selected_keys.clone();
let ts = f_p(p, "timeScale").unwrap_or(1.0);
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
let vs = f_p(p, "valueScale").unwrap_or(1.0);
@@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
};
let touch_values = vs != 1.0 || voff != 0.0;
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
s.edit("Transform Keyframes", merge, |proj, st| {
if let Some(b) = base {
*proj = (*b).clone();
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &sel {
@@ -136,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform Keyframes",
[],
None,
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?, fromStart?: bool (with merge: values are the whole transform since the drag started)}",
has_keys,
transform
),
+19
View File
@@ -938,3 +938,22 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
assert!(s.state.selected_keys.is_empty());
}
#[test]
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]);
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
// Squeezed onto one frame on the way, then back: the three keys survive.
for k in [0.5, 0.01, 0.0001, 1.0] {
s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap();
}
assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]);
// A slow move in tiny steps still arrives (each step is the whole offset).
s.history.merge_key = None;
for i in 1..=30 {
s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap();
}
assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]);
}