diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 81a36f6..7bed42f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -11,6 +11,7 @@ pub mod add_layer; pub mod add_shape; pub mod modify_shape_path; pub mod move_clip_instances; +pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; pub mod set_document_properties; @@ -54,6 +55,7 @@ pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; pub use modify_shape_path::ModifyGraphAction; pub use move_clip_instances::MoveClipInstancesAction; +pub use reorder_clip_instances::ReorderClipInstancesAction; pub use paint_bucket::PaintBucketAction; pub use remove_effect::RemoveEffectAction; pub use set_document_properties::SetDocumentPropertiesAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/reorder_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/reorder_clip_instances.rs new file mode 100644 index 0000000..ae429ec --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/reorder_clip_instances.rs @@ -0,0 +1,78 @@ +//! Reorder clip instances within a layer's stacking order (Send to Back / Bring to Front). +//! +//! A layer's `clip_instances` Vec order *is* the stacking order — the last element renders on top +//! (hit-testing walks it in reverse). Geometry renders underneath and is unaffected. This action +//! moves the selected instances to the front (end) or back (start) of that Vec. + +use crate::action::Action; +use crate::clip::ClipInstance; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +pub struct ReorderClipInstancesAction { + layer_id: Uuid, + instance_ids: Vec, + /// `true` = bring to front (top), `false` = send to back (bottom). + to_front: bool, + /// Full instance order captured on `execute`, for `rollback`. + old_order: Option>, +} + +impl ReorderClipInstancesAction { + pub fn new(layer_id: Uuid, instance_ids: Vec, to_front: bool) -> Self { + Self { layer_id, instance_ids, to_front, old_order: None } + } +} + +/// The clip-instance stack for a layer, if it has one (Group/Raster/Text don't). +fn clip_instances_mut<'a>(document: &'a mut Document, layer_id: &Uuid) -> Option<&'a mut Vec> { + match document.get_layer_mut(layer_id)? { + AnyLayer::Vector(l) => Some(&mut l.clip_instances), + AnyLayer::Audio(l) => Some(&mut l.clip_instances), + AnyLayer::Video(l) => Some(&mut l.clip_instances), + AnyLayer::Effect(l) => Some(&mut l.clip_instances), + _ => None, + } +} + +impl Action for ReorderClipInstancesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instances = clip_instances_mut(document, &self.layer_id) + .ok_or_else(|| "Layer has no clip-instance stack".to_string())?; + self.old_order = Some(instances.iter().map(|c| c.id).collect()); + + let mut selected = Vec::new(); + let mut rest = Vec::new(); + for ci in instances.drain(..) { + if self.instance_ids.contains(&ci.id) { + selected.push(ci); + } else { + rest.push(ci); + } + } + if self.to_front { + rest.extend(selected); // selected last → rendered on top + *instances = rest; + } else { + selected.extend(rest); // selected first → rendered at the bottom (of the stack) + *instances = selected; + } + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(order) = self.old_order.clone() else { + return Ok(()); + }; + let instances = clip_instances_mut(document, &self.layer_id) + .ok_or_else(|| "Layer has no clip-instance stack".to_string())?; + let rank = |id: &Uuid| order.iter().position(|o| o == id).unwrap_or(usize::MAX); + instances.sort_by(|a, b| rank(&a.id).cmp(&rank(&b.id))); + Ok(()) + } + + fn description(&self) -> String { + if self.to_front { "Bring to Front".to_string() } else { "Send to Back".to_string() } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index ca21996..3a6f03b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2948,6 +2948,39 @@ impl EditorApp { } } + /// Send the selected clip/group instances to the back or front of their layer's stacking order. + fn reorder_selected_clips(&mut self, to_front: bool) { + use lightningbeam_core::layer::AnyLayer; + let Some(layer_id) = self.active_layer_id else { + return; + }; + let ids: Vec = { + let document = self.action_executor.document(); + let Some(layer) = document.get_layer(&layer_id) else { + return; + }; + let instances: &[lightningbeam_core::clip::ClipInstance] = match layer { + AnyLayer::Vector(l) => &l.clip_instances, + AnyLayer::Audio(l) => &l.clip_instances, + AnyLayer::Video(l) => &l.clip_instances, + AnyLayer::Effect(l) => &l.clip_instances, + _ => &[], + }; + instances + .iter() + .filter(|ci| self.selection.contains_clip_instance(&ci.id)) + .map(|ci| ci.id) + .collect() + }; + if ids.is_empty() { + return; + } + let action = lightningbeam_core::actions::ReorderClipInstancesAction::new(layer_id, ids, to_front); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to reorder clip instances: {e}"); + } + } + /// Duplicate the selected clip instances on the active layer. /// Each duplicate is placed immediately after the original clip. fn duplicate_selected_clips(&mut self) { @@ -3588,12 +3621,10 @@ impl EditorApp { } } MenuAction::SendToBack => { - println!("Menu: Send to Back"); - // TODO: Implement send to back + self.reorder_selected_clips(false); } MenuAction::BringToFront => { - println!("Menu: Bring to Front"); - // TODO: Implement bring to front + self.reorder_selected_clips(true); } MenuAction::SplitClip => { self.split_clips_at_playhead(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 01adcd7..bef45be 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -11537,14 +11537,23 @@ impl StagePane { // A clip instance is already a clip — no "convert" here. items.push(("Cut".into(), MenuAction::Cut)); items.push(("Copy".into(), MenuAction::Copy)); + items.push(("Paste".into(), MenuAction::Paste)); items.push(("Duplicate".into(), MenuAction::DuplicateClip)); items.push(("Delete".into(), MenuAction::Delete)); + items.push(("Send to back".into(), MenuAction::SendToBack)); + items.push(("Bring to front".into(), MenuAction::BringToFront)); } else if shared.selection.has_geometry_selection() { // Geometry can be gathered up into a movie clip. items.push(("Cut".into(), MenuAction::Cut)); items.push(("Copy".into(), MenuAction::Copy)); + items.push(("Paste".into(), MenuAction::Paste)); items.push(("Convert to movie clip".into(), MenuAction::ConvertToMovieClip)); items.push(("Delete".into(), MenuAction::Delete)); + items.push(("Send to back".into(), MenuAction::SendToBack)); + items.push(("Bring to front".into(), MenuAction::BringToFront)); + } else { + // Nothing selected — offer paste onto the stage. + items.push(("Paste".into(), MenuAction::Paste)); } if !items.is_empty() { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 663678b..b139a57 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -268,6 +268,9 @@ pub struct TimelinePane { /// Is the user panning the timeline? is_panning: bool, last_pan_pos: Option, + /// Manual long-press tracking for the mobile context menu: press start time + position. + lp_time: Option, + lp_pos: egui::Pos2, /// Clip drag state (None if not dragging) clip_drag_state: Option, @@ -788,6 +791,8 @@ impl TimelinePane { is_scrubbing: false, is_panning: false, last_pan_pos: None, + lp_time: None, + lp_pos: egui::Pos2::ZERO, clip_drag_state: None, drag_offset: 0.0, drag_anchor_start: 0.0, @@ -5837,9 +5842,10 @@ impl PaneRenderer for TimelinePane { } ui.set_clip_rect(original_clip_rect); - // Context menu: detect right-click on clips or empty timeline space + // Context menu: detect right-click on clips or empty timeline space (desktop only — mobile + // uses the long-press menu below). let mut just_opened_menu = false; - let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); + let secondary_clicked = !shared.is_mobile && ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); if secondary_clicked { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if content_rect.contains(pos) { @@ -5859,6 +5865,62 @@ impl PaneRenderer for TimelinePane { } } + // Mobile: a manual long-press (hold in place ~0.4s) builds a persistent context menu rendered + // by the shell — clip actions on a clip, animation (keyframe/tween) actions on an empty lane. + let mut long_press: Option = None; + if shared.is_mobile { + let now = ui.input(|i| i.time); + let down = ui.input(|i| i.pointer.any_down()); + let ptr = ui.input(|i| i.pointer.latest_pos()); + if ui.input(|i| i.pointer.primary_pressed()) { + match ptr { + Some(p) if content_rect.contains(p) => { + self.lp_time = Some(now); + self.lp_pos = p; + } + _ => self.lp_time = None, + } + } + if !down { + self.lp_time = None; + } + if let Some(t0) = self.lp_time { + let moved = ptr.map_or(0.0, |p| (p - self.lp_pos).length()); + if moved > 8.0 { + self.lp_time = None; + } else if now - t0 >= 0.4 { + self.lp_time = None; + long_press = Some(self.lp_pos); + } + } + } + if let Some(pos) = long_press { + use crate::menu::MenuAction; + let mut items: Vec<(String, MenuAction)> = Vec::new(); + if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref(), &audio_cache) { + if !shared.selection.contains_clip_instance(&clip_id) { + shared.selection.select_only_clip_instance(clip_id); + } + *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); + items.push(("Split clip".into(), MenuAction::SplitClip)); + items.push(("Duplicate clip".into(), MenuAction::DuplicateClip)); + items.push(("Cut".into(), MenuAction::Cut)); + items.push(("Copy".into(), MenuAction::Copy)); + items.push(("Paste".into(), MenuAction::Paste)); + items.push(("Delete".into(), MenuAction::Delete)); + } else { + items.push(("New keyframe".into(), MenuAction::NewKeyframe)); + items.push(("New blank keyframe".into(), MenuAction::NewBlankKeyframe)); + items.push(("Add keyframe at playhead".into(), MenuAction::AddKeyframeAtPlayhead)); + items.push(("Duplicate keyframe".into(), MenuAction::DuplicateKeyframe)); + items.push(("Delete frame".into(), MenuAction::DeleteFrame)); + items.push(("Add motion tween".into(), MenuAction::AddMotionTween)); + items.push(("Add shape tween".into(), MenuAction::AddShapeTween)); + items.push(("Paste".into(), MenuAction::Paste)); + } + *shared.mobile_context_menu = Some(crate::panes::MobileContextMenu { pos, items }); + } + // Render context menu if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip { let has_clip = ctx_clip_id.is_some();