Mobile context-menu coverage + z-order
Hook up menu-bar actions that lacked a mobile affordance, and implement z-order. - Stage/object long-press menu (stage.rs): add Paste (on clip, geometry, and empty stage) and Send to Back / Bring to Front on the selection. - Timeline long-press menu (timeline.rs): the timeline had no mobile context menu — add one via manual long-press detection. Clip actions (Split, Duplicate, Cut, Copy, Paste, Delete) on a clip; animation actions (New/Blank keyframe, Add keyframe at playhead, Duplicate keyframe, Delete frame, Add motion/shape tween) on an empty lane. Gate the desktop right-click menu to !is_mobile so mobile shows only the long-press menu. - Implement Send to Back / Bring to Front (were // TODO no-ops): new undoable ReorderClipInstancesAction in lightningbeam-core reorders the selected instances within their layer's clip_instances Vec (stacking order; last = on top; geometry stays flattened underneath). Wired via handle_menu_action.
This commit is contained in:
parent
4f66ddb515
commit
2cbc35f181
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Uuid>,
|
||||
/// `true` = bring to front (top), `false` = send to back (bottom).
|
||||
to_front: bool,
|
||||
/// Full instance order captured on `execute`, for `rollback`.
|
||||
old_order: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
impl ReorderClipInstancesAction {
|
||||
pub fn new(layer_id: Uuid, instance_ids: Vec<Uuid>, 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<ClipInstance>> {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<uuid::Uuid> = {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -268,6 +268,9 @@ pub struct TimelinePane {
|
|||
/// Is the user panning the timeline?
|
||||
is_panning: bool,
|
||||
last_pan_pos: Option<egui::Pos2>,
|
||||
/// Manual long-press tracking for the mobile context menu: press start time + position.
|
||||
lp_time: Option<f64>,
|
||||
lp_pos: egui::Pos2,
|
||||
|
||||
/// Clip drag state (None if not dragging)
|
||||
clip_drag_state: Option<ClipDragType>,
|
||||
|
|
@ -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<egui::Pos2> = 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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue