Merge pull request #634 from Ni-zav/fix/project-multiselect-drag-351-s20261010T1753Z-f4c2

This commit is contained in:
Claude committed 2026-10-10 18:51:15 +00:00
commit 1c68166b2e
4 files changed
+85 -12

No files matched your search

+24
View File
@@ -78,6 +78,30 @@ pub enum DragPayload {
Files(Vec<String>),
}
/// 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<u64> {
use effectcraft_engine::project::{FootageKind, ItemId, ItemKind};
let selected = &app.session.state.project_selection;
let candidates: Vec<u64> = 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 {
+7 -2
View File
@@ -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})))
}
+16 -10
View File
@@ -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;
}
}
}
}
+38
View File
@@ -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]