The Composition viewer ignored drags from other panels. Now, as in After Effects: - A Project item dropped on the viewer becomes a layer centred where it lands (above the selected layer, like any new layer); while dragging, the viewer is outlined and the layer's frame follows the pointer. - Media Browser files do the same; files dropped from the OS file manager do too when the platform reports where the pointer is during the drag (otherwise they import as before). - An effect from Effects & Presets goes on the layer under the pointer, which is outlined while dragging. layer.addItem and layer.newModel take `position` ([x, y] comp px). file.import takes `addToComp` with `time`, `index` and `position`; mediaBrowser.import shares it (file::add_to_comp): layered files go in as their comps, the comp they were dropped in stays open, and items that can't be layers are reported in `errors`. viewer::layer_at is the hit test pick() and the drop share. Tests: ui_drag_drop.rs (project item and effect dropped on the viewer) and imported_files_go_into_the_comp_where_they_were_dropped (engine). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
78f49e09ab
commit
8ef1a28bc4
|
|
@ -1,7 +1,7 @@
|
|||
//! File menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{FootageKind, ItemKind, Project};
|
||||
use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
|
|
@ -101,6 +101,54 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
open(s, &json!({"path": path}))
|
||||
}
|
||||
|
||||
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
|
||||
/// also become layers of the active comp ([`add_to_comp`]).
|
||||
fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = s.active_comp_id();
|
||||
let mut r = import(s, p)?;
|
||||
let errors = add_to_comp(s, &r, target, p);
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
e.extend(errors.into_iter().map(Value::from));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// With `addToComp`, put what an import (its result `r`) made into `target`, the comp that was
|
||||
/// active before it, one layer under the other, at the drop's `time`, `index` and `position`
|
||||
/// (as in `layer.addItem`): the comps of layered files (their layers' footage stays in the
|
||||
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
|
||||
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
|
||||
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let in_comps: std::collections::HashSet<u64> = comps
|
||||
.iter()
|
||||
.filter_map(|c| s.project.comp(ItemId(*c)))
|
||||
.flat_map(|c| c.layers.iter())
|
||||
.filter_map(|l| match l.source {
|
||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
|
||||
let mut errors = vec![];
|
||||
for (k, item) in add.into_iter().enumerate() {
|
||||
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
|
||||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||||
params["index"] = json!(i.saturating_add(k as u64));
|
||||
}
|
||||
if let Err(e) = s.execute("layer.addItem", params) {
|
||||
errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
if s.active_comp_id() != Some(cid) {
|
||||
s.open_comp(cid);
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
|
|
@ -528,9 +576,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem)}",
|
||||
always,
|
||||
import
|
||||
import_cmd
|
||||
),
|
||||
cmd!(
|
||||
"file.projectSettings",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,32 @@ pub(crate) fn index_p(p: &Value, cmd: &str) -> Result<Option<usize>> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A `position` parameter: `[x, y]` in comp pixels (finite).
|
||||
pub(crate) fn position_p(p: &Value, cmd: &str) -> Result<Option<[f64; 2]>> {
|
||||
match p.get("position") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]))
|
||||
.filter(|c| c.iter().all(|x| x.is_finite()))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a new layer's Position at `pos` (comp pixels).
|
||||
pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) {
|
||||
if let Some([x, y]) = pos
|
||||
&& let Some(pr) = l.props.prop_mut("transform/position")
|
||||
{
|
||||
let z = match pr.value {
|
||||
KV::Vec3(v) => v[2],
|
||||
_ => 0.0,
|
||||
};
|
||||
pr.value = KV::Vec3([x, y, z]);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer.
|
||||
pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option<usize>) -> Result<LayerId> {
|
||||
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
|
||||
|
|
@ -277,13 +303,17 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index")}));
|
||||
return super::model3d::new_model(
|
||||
s,
|
||||
&serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}),
|
||||
);
|
||||
}
|
||||
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
|
||||
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
|
||||
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
|
||||
};
|
||||
let index = index_p(p, "layer.addItem")?;
|
||||
let position = position_p(p, "layer.addItem")?;
|
||||
let fr = comp.frame_rate;
|
||||
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
|
||||
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
|
||||
|
|
@ -293,6 +323,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
|
|
@ -1267,7 +1298,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add Footage to Comp",
|
||||
["File"],
|
||||
Some("Cmd+/"),
|
||||
"{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), duration? (s, for a still)}",
|
||||
"{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}",
|
||||
has_comp,
|
||||
add_item
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Nod
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::{color_p, index_p, insert_layer, insert_layer_at};
|
||||
use super::layer::{color_p, index_p, insert_layer, insert_layer_at, place, position_p};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
|
|
@ -120,6 +120,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let fr = comp.frame_rate;
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
|
||||
let index = index_p(p, "layer.newModel")?;
|
||||
let position = position_p(p, "layer.newModel")?;
|
||||
let id = s.edit("New 3D Model Layer", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
|
||||
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
|
||||
|
|
@ -129,6 +130,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = comp.duration.max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "item": item.0}))
|
||||
|
|
@ -350,7 +352,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"3D Model Layer",
|
||||
[],
|
||||
None,
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position)}",
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
|
|
|
|||
|
|
@ -104,20 +104,14 @@ fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !browser(s)?.fetch(&list, p) {
|
||||
return Ok(json!({"pending": true, "paths": list}));
|
||||
}
|
||||
let r = s.execute("file.import", json!({"paths": paths}))?;
|
||||
// Add to the active comp (drag into the timeline).
|
||||
if b_p(p, "addToComp").unwrap_or(false)
|
||||
&& s.active_comp_id().is_some()
|
||||
&& let Some(items) = r.get("items").and_then(Value::as_array)
|
||||
let target = s.active_comp_id();
|
||||
let mut r = s.execute("file.import", json!({"paths": paths}))?;
|
||||
// Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped.
|
||||
let errors = super::file::add_to_comp(s, &r, target, p);
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
// Where a drop put them (`time`, `index`): one under the other.
|
||||
for (k, it) in items.iter().enumerate() {
|
||||
let mut add = json!({"item": it, "time": p.get("time")});
|
||||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||||
add["index"] = json!(i.saturating_add(k as u64));
|
||||
}
|
||||
s.execute("layer.addItem", add)?;
|
||||
}
|
||||
e.extend(errors.into_iter().map(Value::from));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
|
@ -243,7 +237,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Import",
|
||||
[],
|
||||
None,
|
||||
"{paths, addToComp?, time?, index? (where the layers go, as in layer.addItem)}",
|
||||
"{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}",
|
||||
browser_enabled,
|
||||
browser_import
|
||||
),
|
||||
|
|
|
|||
|
|
@ -68,6 +68,34 @@ fn add_item_at_a_stack_index_and_time() {
|
|||
assert!(s.execute("layer.addItem", json!({"item": clip, "index": 0})).is_err(), "1-based");
|
||||
}
|
||||
|
||||
/// Files dropped on the Timeline or the Composition viewer (`file.import {addToComp}`) become
|
||||
/// layers of the comp that was active, one under the other, where they were dropped (#85, #89).
|
||||
#[test]
|
||||
fn imported_files_go_into_the_comp_where_they_were_dropped() {
|
||||
use effectcraft_project::{Footage, FootageKind};
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bg"})).unwrap();
|
||||
let params = json!({"paths": ["/drop/a.png", "/drop/b.png"], "addToComp": true, "position": [40.0, 50.0], "index": 1, "time": 1.0});
|
||||
s.execute("file.import", params).unwrap();
|
||||
let comp = s.active_comp().unwrap();
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["a.png", "b.png", "Bg"]);
|
||||
for l in &comp.layers[..2] {
|
||||
assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0])));
|
||||
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0));
|
||||
}
|
||||
// Without `addToComp` they only import.
|
||||
s.execute("file.import", json!({"paths": ["/drop/c.png"]})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
|
||||
assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y");
|
||||
}
|
||||
|
||||
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
|
||||
#[test]
|
||||
fn audio_lock_and_shy_keep_cached_layers() {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ pub mod timeline;
|
|||
pub mod tracker;
|
||||
pub mod unsaved;
|
||||
pub mod viewer;
|
||||
pub mod viewer_drop;
|
||||
pub mod viewer_overlays;
|
||||
pub mod viewer_text;
|
||||
pub mod viewer_tools;
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ pub(crate) fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) {
|
|||
}
|
||||
|
||||
/// Layer → comp matrix and its bounds quad (in comp pixels).
|
||||
fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> {
|
||||
pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> {
|
||||
let b = effectcraft_engine::render::content_bounds(ctx, layer)?;
|
||||
let (m, _) = l2c(ctx, layer);
|
||||
let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| {
|
||||
|
|
@ -881,6 +881,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
// Project items, files and effects dropped on the viewer.
|
||||
super::viewer_drop::show(app, ui, &painter, &map, &ectx);
|
||||
|
||||
// Interaction.
|
||||
let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag());
|
||||
let menu_hits_id = egui::Id::new(("viewer-menu-hits", cid.0));
|
||||
|
|
@ -1733,9 +1736,14 @@ pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator<Item =
|
|||
.filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo))
|
||||
}
|
||||
|
||||
/// Topmost selectable layer under a comp point.
|
||||
pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> {
|
||||
selectable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q)))
|
||||
}
|
||||
|
||||
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
|
||||
pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
|
||||
let hit = selectable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))).map(|l| l.id)?;
|
||||
let hit = layer_at(ectx, cpt).map(|l| l.id)?;
|
||||
if toggle {
|
||||
let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true}));
|
||||
} else if !app.session.state.selected_layers.contains(&hit) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
//! Dropping onto the Composition viewer, as in After Effects: footage, comps and solids from the
|
||||
//! Project panel and files from the Media Browser become layers centred where they are dropped
|
||||
//! (#85), and an effect from Effects & Presets goes on the layer under the pointer, outlined
|
||||
//! while it is dragged (#88). Agents do the same with `layer.addItem {position}`,
|
||||
//! `mediaBrowser.import {position}` and `effect.apply {layers}`.
|
||||
|
||||
use effectcraft_engine::project::{ItemId, ItemKind};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use egui::{Rect, Stroke, StrokeKind, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::DragPayload;
|
||||
use super::viewer::{ViewerMap, layer_at, layer_quad};
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
/// While something is dragged over the viewer, show where it goes; drop it on release.
|
||||
pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(payload) = egui::DragAndDrop::payload::<DragPayload>(&ctx) else { return };
|
||||
if matches!(*payload, DragPayload::Property { .. }) {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
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]}))
|
||||
}),
|
||||
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})))
|
||||
}
|
||||
DragPayload::Files(paths) => Some(("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))),
|
||||
DragPayload::Property { .. } => None,
|
||||
};
|
||||
if action.as_ref().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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The size in comp pixels of a layer made from an item of `kind` (`None`: nothing to see).
|
||||
fn frame_size(kind: &ItemKind, comp_pixel_aspect: f64) -> Option<[f32; 2]> {
|
||||
let (w, h, pixel_aspect) = match kind {
|
||||
ItemKind::Comp(c) => (c.width, c.height, c.pixel_aspect),
|
||||
ItemKind::Footage(f) if f.has_video => (f.width, f.height, f.pixel_aspect),
|
||||
ItemKind::Solid(s) => (s.width, s.height, s.pixel_aspect),
|
||||
_ => return None,
|
||||
};
|
||||
let stretch = if comp_pixel_aspect > 0.0 && pixel_aspect > 0.0 { pixel_aspect / comp_pixel_aspect } else { 1.0 };
|
||||
(w > 0 && h > 0).then_some([(w as f64 * stretch) as f32, h as f32])
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
|
||||
/// Files dropped on the window are imported (layered files as Default Drag Import As says).
|
||||
/// Dropped on the Composition viewer, they also become layers centred there, as in After
|
||||
/// Effects (#85), when the platform reports where the pointer is during file drags.
|
||||
fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let paths: Vec<String> =
|
||||
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect());
|
||||
|
|
@ -34,7 +36,10 @@ fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
app.ui.status = e;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as()});
|
||||
let viewer = app.auto.find("viewer.area").map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])));
|
||||
let at =
|
||||
ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(ctx, p));
|
||||
let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at});
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
//! Dragging Project items into the Timeline (egui_kittest, real pointer drags): they land where
|
||||
//! they are dropped, between layers and, over the time graph, starting there (#89).
|
||||
//! Drag and drop between panels (egui_kittest, real pointer drags), as in After Effects: Project
|
||||
//! items land in the Timeline where they are dropped, between layers and, over the time graph,
|
||||
//! starting there (#89); dropped on the Composition viewer they are centred where they land
|
||||
//! (#85); an effect dropped on the viewer goes on the layer under the pointer (#88).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::panels::viewer::{comp_to_screen, last_fit};
|
||||
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
|
@ -97,3 +102,37 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() {
|
|||
assert_eq!(stack(&h), ["Top", "Middle", "Bottom", "Clip"]);
|
||||
assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time");
|
||||
}
|
||||
|
||||
/// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was
|
||||
/// dropped (above the selected layer, like any new layer).
|
||||
#[test]
|
||||
fn project_items_dropped_on_the_viewer_land_under_the_pointer() {
|
||||
let (mut h, clip) = harness();
|
||||
let item = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap();
|
||||
drag(&mut h, item, to, Modifiers::NONE);
|
||||
assert_eq!(stack(&h), ["Clip", "Top", "Middle", "Bottom"]);
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
let Some(KV::Vec3(p)) = comp.layers[0].props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") };
|
||||
// One screen point is up to 1/zoom comp pixels.
|
||||
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]
|
||||
fn effects_dropped_on_the_viewer_go_on_the_layer_under_the_pointer() {
|
||||
let (mut h, _) = harness();
|
||||
let (top, middle) = (layer_id(&h, "Top"), layer_id(&h, "Middle"));
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [middle]})).unwrap();
|
||||
h.state_mut().show_panel(PanelKind::EffectsPresets);
|
||||
h.state_mut().ui.effects_search = "Gaussian Blur".into();
|
||||
h.run_steps(3);
|
||||
let fx = rect(&h, "effects.item.ec.blur.gaussian").center();
|
||||
let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap();
|
||||
drag(&mut h, fx, to, Modifiers::NONE);
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
let effects = |id: u64| comp.layer(effectcraft_engine::project::LayerId(id)).unwrap().effects().map_or(0, |fx| fx.groups().count());
|
||||
assert_eq!((effects(top), effects(middle)), (1, 0), "on Top, which is under the pointer");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue