From 1922e621c22be3125fdb8d03282c52645f8152b6 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:35 +0700 Subject: [PATCH] fix(ui): drop all selected Project items into compositions (#351) Preserve Project multi-selection when dropping onto the Timeline or Composition viewer. Keep stack order and drop position, skip non-layer project items, and add pointer-drag regression coverage. --- crates/ui-egui/src/panels/mod.rs | 24 +++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 9 ++++-- crates/ui-egui/src/panels/viewer_drop.rs | 26 +++++++++------- crates/ui-egui/tests/ui_drag_drop.rs | 38 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ab5a1c8f..76691dce 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -78,6 +78,30 @@ pub enum DragPayload { Files(Vec), } +/// Layers carried by a Project item drag. Dragging a selected item uses the +/// complete selection, while dragging an unselected item uses only that item. +/// Folders and data files are not composition layers. +pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec { + use effectcraft_engine::project::{FootageKind, ItemId, ItemKind}; + + let selected = &app.session.state.project_selection; + let candidates: Vec = if selected.iter().any(|id| id.0 == dragged) { + selected.iter().map(|id| id.0).collect() + } else { + vec![dragged] + }; + candidates + .into_iter() + .filter(|id| { + app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind { + ItemKind::Comp(_) | ItemKind::Solid(_) => true, + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Folder => false, + }) + }) + .collect() +} + /// The current time formatted per project settings (timecode with `;` for drop-frame, frames /// or Feet + Frames). pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439b..878a577f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2959,9 +2959,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { rp.galley(tag.min, g, Color32::WHITE); } if released { - let (index, time) = (json!(index), json!(start.map(Tick::seconds))); + let time = json!(start.map(Tick::seconds)); match payload.as_ref() { - crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id, "index": index, "time": time}))), + crate::panels::DragPayload::Item(id) => { + // Insert each selected item at the drop point, in selection order. + for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() { + actions.push(("layer.addItem".into(), json!({"item": item, "index": index + offset, "time": time.clone()}))); + } + } crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 31d426b3..dec8d380 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -23,32 +23,38 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); - let action: Option<(&str, Value)> = match payload.as_ref() { + let actions: Vec<(&str, Value)> = match payload.as_ref() { DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| { if let Some((_, q, _)) = layer_quad(ectx, l) { painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); } ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) - }), + }).into_iter().collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { painter.rect_stroke(Rect::from_center_size(ptr, vec2(w, h) * map.zoom), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle); } - Some(("layer.addItem", json!({"item": item, "position": at}))) + // New layers go above the current selection, so insert in reverse + // to preserve the order of the dragged Project items. + crate::panels::project_drop_items(app, *item) + .into_iter() + .rev() + .map(|item| ("layer.addItem", json!({"item": item, "position": at}))) + .collect() } - DragPayload::Files(paths) => Some(("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))), - DragPayload::Property { .. } => None, + DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], + DragPayload::Property { .. } => vec![], }; - if action.as_ref().is_some_and(|(id, _)| *id != "effect.apply") { + if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { egui::DragAndDrop::clear_payload(&ctx); - if let Some((id, params)) = action - && let Err(e) = crate::menus::invoke(app, &ctx, id, params) - { - app.ui.status = e; + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { + app.ui.status = e; + } } } } diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9738fef2..56f9ffd7 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -123,6 +123,44 @@ fn project_items_dropped_on_the_viewer_land_under_the_pointer() { assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); } +/// #351: dragging one of several selected Project items inserts all the items, +/// in selection order, instead of only the item the drag started on. +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_timeline() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = + vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top"))); + drag(&mut h, from, pos2(top.center().x, top.max.y - 2.0), Modifiers::NONE); + assert_eq!(stack(&h), ["Top", "Clip", "Other", "Middle", "Bottom"]); + assert_eq!(in_point(&h, 1), in_point(&h, 2), "both layers use the drop time"); +} + +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_viewer() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = + vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); + for layer in &h.state().session.active_comp().unwrap().layers[..2] { + let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; + let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; + assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); + } +} + /// #88: an effect dragged from Effects & Presets onto the viewer goes on the layer under the /// pointer, not the selected one. #[test]