Browse Source

Multiple selection and move

flabbet 1 year ago
parent
commit
72a2c13c15

+ 24 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs

@@ -142,6 +142,15 @@ internal class DocumentUpdater
             case KeyFrameVisibility_ChangeInfo info:
                 ProcessKeyFrameVisibility(info);
                 break;
+            case AddSelectedKeyFrame_PassthroughAction info:
+                ProcessAddSelectedKeyFrame(info);
+                break;
+            case RemoveSelectedKeyFrame_PassthroughAction info:
+                ProcessRemoveSelectedKeyFrame(info);
+                break;
+            case ClearSelectedKeyFrames_PassthroughAction info:
+                ClearSelectedKeyFrames(info);
+                break;
         }
     }
 
@@ -434,4 +443,19 @@ internal class DocumentUpdater
     {
         doc.AnimationHandler.SetKeyFrameVisibility(info.KeyFrameId, info.IsVisible);
     }
+    
+    private void ProcessAddSelectedKeyFrame(AddSelectedKeyFrame_PassthroughAction info)
+    {
+        doc.AnimationHandler.AddSelectedKeyFrame(info.KeyFrameGuid);
+    }
+    
+    private void ProcessRemoveSelectedKeyFrame(RemoveSelectedKeyFrame_PassthroughAction info)
+    {
+        doc.AnimationHandler.RemoveSelectedKeyFrame(info.KeyFrameGuid);
+    }
+    
+    private void ClearSelectedKeyFrames(ClearSelectedKeyFrames_PassthroughAction info)
+    {
+        doc.AnimationHandler.ClearSelectedKeyFrames();
+    }
 }

+ 6 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -318,6 +318,12 @@ internal class DocumentOperationsModule : IDocumentOperations
     /// Clears the soft selection
     /// </summary>
     public void ClearSoftSelectedMembers() => Internals.ActionAccumulator.AddActions(new ClearSoftSelectedMembers_PassthroughAction());
+    
+    public void AddSelectedKeyFrame(Guid keyFrameGuid) => Internals.ActionAccumulator.AddActions(new AddSelectedKeyFrame_PassthroughAction(keyFrameGuid));
+    
+    public void RemoveSelectedKeyFrame(Guid keyFrameGuid) => Internals.ActionAccumulator.AddActions(new RemoveSelectedKeyFrame_PassthroughAction(keyFrameGuid));
+    
+    public void ClearSelectedKeyFrames() => Internals.ActionAccumulator.AddActions(new ClearSelectedKeyFrames_PassthroughAction());
 
     /// <summary>
     /// Undo last change

+ 6 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentPassthroughActions/AddSelectedKeyFrame_PassthroughAction.cs

@@ -0,0 +1,6 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.AvaloniaUI.Models.DocumentPassthroughActions;
+
+internal record AddSelectedKeyFrame_PassthroughAction(Guid KeyFrameGuid) : IChangeInfo, IAction;

+ 6 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentPassthroughActions/ClearSelectedKeyFrames_PassthroughAction.cs

@@ -0,0 +1,6 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.AvaloniaUI.Models.DocumentPassthroughActions;
+
+internal record ClearSelectedKeyFrames_PassthroughAction() : IChangeInfo, IAction;

+ 6 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentPassthroughActions/RemoveSelectedKeyFrame_PassthroughAction.cs

@@ -0,0 +1,6 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.AvaloniaUI.Models.DocumentPassthroughActions;
+
+internal record RemoveSelectedKeyFrame_PassthroughAction(Guid KeyFrameGuid) : IChangeInfo, IAction;

+ 3 - 0
src/PixiEditor.AvaloniaUI/Models/Handlers/IAnimationHandler.cs

@@ -11,4 +11,7 @@ internal interface IAnimationHandler
     public bool FindKeyFrame<T>(Guid guid, out T keyFrameHandler) where T : IKeyFrameHandler;
     internal void AddKeyFrame(IKeyFrameHandler keyFrame);
     internal void RemoveKeyFrame(Guid keyFrameId);
+    public void AddSelectedKeyFrame(Guid keyFrameId);
+    public void RemoveSelectedKeyFrame(Guid keyFrameId);
+    public void ClearSelectedKeyFrames();
 }

+ 21 - 16
src/PixiEditor.AvaloniaUI/Styles/Templates/Timeline.axaml

@@ -31,8 +31,8 @@
                         <Button DockPanel.Dock="Left" Classes="pixi-icon"
                                 Content="{DynamicResource icon-trash}"
                                 Command="{TemplateBinding DeleteKeyFrameCommand}"
-                                IsEnabled="{Binding !!SelectedKeyFrame, RelativeSource={RelativeSource TemplatedParent}}"
-                                CommandParameter="{Binding SelectedKeyFrame.Id, RelativeSource={RelativeSource TemplatedParent}}" />
+                                IsEnabled="{Binding SelectedKeyFrames.Count, RelativeSource={RelativeSource TemplatedParent}}"
+                                CommandParameter="{Binding SelectedKeyFrames, RelativeSource={RelativeSource TemplatedParent}}" />
                         <input:NumberInput Min="1"
                                            Value="{Binding Fps, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
                         <Panel>
@@ -123,7 +123,7 @@
                                 <Interaction.Behaviors>
                                     <EventTriggerBehavior EventName="PointerPressed">
                                         <InvokeCommandAction
-                                            Command="{Binding SelectKeyFrameCommand,
+                                            Command="{Binding ClearSelectedKeyFramesCommand,
                                                         RelativeSource={RelativeSource FindAncestor, AncestorType=animations:Timeline}}"
                                             CommandParameter="{x:Null}" />
                                     </EventTriggerBehavior>
@@ -158,6 +158,7 @@
                                             <animations:KeyFrame
                                                 Scale="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=animations:Timeline}}"
                                                 IsEnabled="{Binding IsVisible}"
+                                                IsSelected="{Binding IsSelected, Mode=TwoWay}"
                                                 Item="{Binding}">
                                                 <animations:KeyFrame.Width>
                                                     <MultiBinding Converter="{converters:DurationToWidthConverter}">
@@ -167,22 +168,22 @@
                                                             Path="Scale" />
                                                     </MultiBinding>
                                                 </animations:KeyFrame.Width>
-                                                <animations:KeyFrame.IsSelected>
-                                                    <MultiBinding>
-                                                        <MultiBinding.Converter>
-                                                            <converters:AreEqualConverter />
-                                                        </MultiBinding.Converter>
-                                                        <Binding Path="SelectedKeyFrame"
-                                                                 RelativeSource="{RelativeSource FindAncestor, AncestorType=animations:Timeline}" />
-                                                        <Binding
-                                                            RelativeSource="{RelativeSource Self}"
-                                                            Path="Item" />
-                                                    </MultiBinding>
-                                                </animations:KeyFrame.IsSelected>
                                                 <Interaction.Behaviors>
                                                     <EventTriggerBehavior EventName="PointerPressed">
                                                         <InvokeCommandAction
-                                                            Command="{Binding SelectKeyFrameCommand,
+                                                            Command="{Binding PressedKeyFrameCommand,
+                                                        RelativeSource={RelativeSource FindAncestor, AncestorType=animations:Timeline}}"
+                                                            PassEventArgsToCommand="True" />
+                                                    </EventTriggerBehavior>
+                                                    <EventTriggerBehavior EventName="PointerMoved">
+                                                        <InvokeCommandAction
+                                                            Command="{Binding DraggedKeyFrameCommand,
+                                                        RelativeSource={RelativeSource FindAncestor, AncestorType=animations:Timeline}}"
+                                                            PassEventArgsToCommand="True" />
+                                                    </EventTriggerBehavior>
+                                                    <EventTriggerBehavior EventName="PointerCaptureLost">
+                                                        <InvokeCommandAction
+                                                            Command="{Binding ReleasedKeyFrameCommand,
                                                         RelativeSource={RelativeSource FindAncestor, AncestorType=animations:Timeline}}"
                                                             CommandParameter="{Binding}" />
                                                     </EventTriggerBehavior>
@@ -191,6 +192,10 @@
                                         </DataTemplate>
                                     </ItemsControl.DataTemplates>
                                 </ItemsControl>
+                                
+                                <Rectangle Name="PART_SelectionRectangle" HorizontalAlignment="Left" VerticalAlignment="Top"
+                                           IsVisible="False" ZIndex="100" Stroke="{DynamicResource ThemeAccent2Brush}" StrokeThickness="1"
+                                           Fill="{DynamicResource ThemeControlHighlightBrush}" Opacity="0.25"/>
                             </Grid>
                         </ScrollViewer>
                     </Grid>

+ 68 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs

@@ -46,11 +46,38 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         }
     }
 
-    public void DeleteKeyFrame(Guid keyFrameId)
+    public void DeleteKeyFrames(List<Guid> keyFrameIds)
     {
         if (!Document.UpdateableChangeActive)
         {
-            Internals.ActionAccumulator.AddFinishedActions(new DeleteKeyFrame_Action(keyFrameId));
+            for (var i = 0; i < keyFrameIds.Count; i++)
+            {
+                var id = keyFrameIds[i];
+                if(i == keyFrameIds.Count - 1)
+                {
+                    Internals.ActionAccumulator.AddFinishedActions(new DeleteKeyFrame_Action(id));
+                }
+                else
+                {
+                    Internals.ActionAccumulator.AddActions(new DeleteKeyFrame_Action(id));
+                }
+            }
+        }
+    }
+    
+    public void ChangeKeyFramesStartPos(Guid[] infoIds, int infoDelta)
+    {
+        if (!Document.UpdateableChangeActive)
+        {
+            Internals.ActionAccumulator.AddActions(new KeyFramesStartPos_Action(infoIds.ToList(), infoDelta));
+        }
+    }
+    
+    public void EndKeyFramesStartPos()
+    {
+        if (!Document.UpdateableChangeActive)
+        {
+            Internals.ActionAccumulator.AddFinishedActions(new EndKeyFramesStartPos_Action());
         }
     }
     
@@ -104,7 +131,46 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
             parent.Children.Remove(frame);
             keyFrames.NotifyCollectionChanged(NotifyCollectionChangedAction.Remove, (KeyFrameViewModel)frame);
         });
+    }
+
+    public void AddSelectedKeyFrame(Guid keyFrameId)
+    {
+        if (TryFindKeyFrame(keyFrameId, out KeyFrameViewModel keyFrame))
+        {
+            keyFrame.IsSelected = true;
+        }
+    }
+
+    public void RemoveSelectedKeyFrame(Guid keyFrameId)
+    {
+        if (TryFindKeyFrame(keyFrameId, out KeyFrameViewModel keyFrame))
+        {
+            keyFrame.IsSelected = false;
+        }
+    }
+
+    public void ClearSelectedKeyFrames()
+    {
+        var selectedFrames = keyFrames.SelectChildrenBy<KeyFrameViewModel>(x => x.IsSelected);
+        foreach (var frame in selectedFrames)
+        {
+            frame.IsSelected = false;
+        }
+    }
+
+    public void RemoveKeyFrames(List<Guid> keyFrameIds)
+    {
+        List<KeyFrameViewModel> framesToRemove = new List<KeyFrameViewModel>();
+        foreach (var keyFrame in keyFrameIds)
+        {
+            TryFindKeyFrame<KeyFrameViewModel>(keyFrame, out _, (frame, parent) =>
+            {
+                parent.Children.Remove(frame);
+                framesToRemove.Add((KeyFrameViewModel)frame);
+            });
+        }
         
+        keyFrames.NotifyCollectionChanged(NotifyCollectionChangedAction.Remove, framesToRemove);
     }
     
     public bool FindKeyFrame<T>(Guid guid, out T keyFrameHandler) where T : IKeyFrameHandler

+ 26 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameCollection.cs

@@ -1,6 +1,7 @@
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.ComponentModel;
+using PixiEditor.AvaloniaUI.Views.Animations;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Document;
 
@@ -31,9 +32,34 @@ internal class KeyFrameCollection : ObservableCollection<KeyFrameGroupViewModel>
             KeyFrameRemoved?.Invoke(keyFrame);
         }
     }
+    
+    public void NotifyCollectionChanged(NotifyCollectionChangedAction action, List<KeyFrameViewModel> fames)
+    {
+        foreach (var frame in fames)
+        {
+            NotifyCollectionChanged(action, frame);
+        }
+    }
 
     public int FrameCount
     {
         get => Items.Count == 0 ? 0 : Items.Max(x => x.StartFrameBindable + x.DurationBindable);
     }
+
+    public List<T> SelectChildrenBy<T>(Predicate<T> selector)
+    {
+       List<T> result = new List<T>();
+       foreach (var group in Items)
+       {
+           foreach (var child in group.Children)
+           {
+               if (child is T target && selector(target))
+               {
+                   result.Add(target);
+               }
+           }
+       }
+       
+       return result;
+    }
 }

+ 7 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameViewModel.cs

@@ -12,6 +12,7 @@ internal abstract class KeyFrameViewModel : ObservableObject, IKeyFrameHandler
     private int startFrameBindable;
     private int durationBindable;
     private bool isVisibleBindable = true;
+    private bool isSelected;
 
     public DocumentViewModel Document { get; }
     protected DocumentInternalParts Internals { get; }
@@ -77,6 +78,12 @@ internal abstract class KeyFrameViewModel : ObservableObject, IKeyFrameHandler
     public Guid LayerGuid { get; }
     public Guid Id { get; }
 
+    public bool IsSelected
+    {
+        get => isSelected;
+        set => SetProperty(ref isSelected, value);
+    }
+
     protected KeyFrameViewModel(int startFrame, int duration, Guid layerGuid, Guid id,
         DocumentViewModel document, DocumentInternalParts internalParts)
     {

+ 34 - 4
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs

@@ -50,14 +50,44 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
         activeDocument.Operations.SetActiveFrame(newFrame);
     }
     
-    [Command.Basic("PixiEditor.Animation.DeleteKeyFrame", "Delete Key Frame", "Delete a key frame")]
-    public void DeleteKeyFrame(Guid keyFrameId)
+    [Command.Basic("PixiEditor.Animation.DeleteKeyFrames", "Delete key frames", "Delete key frames")]
+    public void DeleteKeyFrames(IList<KeyFrameViewModel> keyFrames)
     {
         var activeDocument = Owner.DocumentManagerSubViewModel.ActiveDocument;
-        if (activeDocument is null || !activeDocument.AnimationDataViewModel.FindKeyFrame<KeyFrameViewModel>(keyFrameId, out _))
+
+        if (activeDocument is null)
             return;
         
-        activeDocument.AnimationDataViewModel.DeleteKeyFrame(keyFrameId);
+        List<Guid> keyFrameIds = keyFrames.Select(x => x.Id).ToList();
+        
+        for(int i = 0; i < keyFrameIds.Count; i++)
+        {
+            if(!activeDocument.AnimationDataViewModel.TryFindKeyFrame<KeyFrameViewModel>(keyFrameIds[i], out _))
+            {
+                keyFrameIds.RemoveAt(i);
+                i--;   
+            }
+        }
+        
+        activeDocument.AnimationDataViewModel.DeleteKeyFrames(keyFrameIds);
+    }
+
+    [Command.Internal("PixiEditor.Animation.ChangeKeyFramesStartPos")]
+    public void ChangeKeyFramesStartPos((Guid[] ids, int delta, bool end) info)
+    {
+        var activeDocument = Owner.DocumentManagerSubViewModel.ActiveDocument;
+
+        if (activeDocument is null)
+            return;
+
+        if (!info.end)
+        {
+            activeDocument.AnimationDataViewModel.ChangeKeyFramesStartPos(info.ids, info.delta);
+        }
+        else
+        {
+            activeDocument.AnimationDataViewModel.EndKeyFramesStartPos();
+        }
     }
     
     [Command.Basic("PixiEditor.Animation.ExportSpriteSheet", "Export Sprite Sheet", "Export the sprite sheet")]

+ 4 - 4
src/PixiEditor.AvaloniaUI/Views/Animations/KeyFrame.cs

@@ -68,9 +68,9 @@ internal class KeyFrame : TemplatedControl
         _resizePanelLeft.PointerCaptureLost += UpdateKeyFrame;
         _resizePanelRight.PointerCaptureLost += UpdateKeyFrame;
 
-        PointerPressed += CapturePointer;
+        /*PointerPressed += CapturePointer;
         PointerMoved += DragOnPointerMoved;
-        PointerCaptureLost += UpdateKeyFrame;
+        PointerCaptureLost += UpdateKeyFrame;*/
 
         if (Item is not KeyFrameGroupViewModel)
         {
@@ -141,7 +141,7 @@ internal class KeyFrame : TemplatedControl
     
     private void DragOnPointerMoved(object? sender, PointerEventArgs e)
     {
-        if (Item is null)
+        /*if (Item is null)
         {
             return;
         }
@@ -150,7 +150,7 @@ internal class KeyFrame : TemplatedControl
         {
             var frame = MousePosToFrame(e, false);
             Item.ChangeFrameLength(frame + clickFrameOffset, Item.DurationBindable);
-        }
+        }*/
     }
 
     private int MousePosToFrame(PointerEventArgs e, bool round = true)

+ 188 - 37
src/PixiEditor.AvaloniaUI/Views/Animations/Timeline.cs

@@ -1,14 +1,18 @@
-using System.Windows.Input;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Windows.Input;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
 using Avalonia.Input;
 using Avalonia.Interactivity;
 using Avalonia.Threading;
 using CommunityToolkit.Mvvm.Input;
 using PixiEditor.AvaloniaUI.Helpers;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiEditor.ChangeableDocument.Actions.Generated;
 
 namespace PixiEditor.AvaloniaUI.Views.Animations;
 
@@ -17,7 +21,8 @@ namespace PixiEditor.AvaloniaUI.Views.Animations;
 [TemplatePart("PART_ContentGrid", typeof(Grid))]
 [TemplatePart("PART_TimelineKeyFramesScroll", typeof(ScrollViewer))]
 [TemplatePart("PART_TimelineHeaderScroll", typeof(ScrollViewer))]
-internal class Timeline : TemplatedControl
+[TemplatePart("PART_SelectionRectangle", typeof(Rectangle))]
+internal class Timeline : TemplatedControl, INotifyPropertyChanged
 {
     private const float MarginMultiplier = 1.5f;
     
@@ -40,12 +45,8 @@ internal class Timeline : TemplatedControl
 
     public static readonly StyledProperty<int> FpsProperty = AvaloniaProperty.Register<Timeline, int>(nameof(Fps), 60);
 
-    public static readonly StyledProperty<KeyFrameViewModel> SelectedKeyFrameProperty =
-        AvaloniaProperty.Register<Timeline, KeyFrameViewModel>(
-            "SelectedKeyFrame");
-
     public static readonly StyledProperty<Vector> ScrollOffsetProperty = AvaloniaProperty.Register<Timeline, Vector>(
-        "ScrollOffset");
+        nameof(ScrollOffset));
 
     public Vector ScrollOffset
     {
@@ -53,12 +54,6 @@ internal class Timeline : TemplatedControl
         set => SetValue(ScrollOffsetProperty, value);
     }
 
-    public KeyFrameViewModel SelectedKeyFrame
-    {
-        get => GetValue(SelectedKeyFrameProperty);
-        set => SetValue(SelectedKeyFrameProperty, value);
-    }
-
     public double Scale
     {
         get => GetValue(ScaleProperty);
@@ -82,6 +77,15 @@ internal class Timeline : TemplatedControl
     public static readonly StyledProperty<double> MinLeftOffsetProperty = AvaloniaProperty.Register<Timeline, double>(
         nameof(MinLeftOffset), 30);
 
+    public static readonly StyledProperty<ICommand> ChangeKeyFramesLengthCommandProperty = AvaloniaProperty.Register<Timeline, ICommand>(
+        nameof(ChangeKeyFramesLengthCommand));
+
+    public ICommand ChangeKeyFramesLengthCommand
+    {
+        get => GetValue(ChangeKeyFramesLengthCommandProperty);
+        set => SetValue(ChangeKeyFramesLengthCommandProperty, value);
+    }
+
     public double MinLeftOffset
     {
         get => GetValue(MinLeftOffsetProperty);
@@ -124,7 +128,14 @@ internal class Timeline : TemplatedControl
         set { SetValue(FpsProperty, value); }
     }
 
-    public ICommand SelectKeyFrameCommand { get; }
+    public ICommand DraggedKeyFrameCommand { get; }
+    public ICommand ReleasedKeyFrameCommand { get; }
+    public ICommand ClearSelectedKeyFramesCommand { get; }
+    public ICommand PressedKeyFrameCommand { get; }
+
+    public IReadOnlyCollection<KeyFrameViewModel> SelectedKeyFrames => KeyFrames != null
+        ? KeyFrames.SelectChildrenBy<KeyFrameViewModel>(x => x.IsSelected).ToList()
+        : [];
 
     private ToggleButton? _playToggle;
     private DispatcherTimer _playTimer;
@@ -133,8 +144,16 @@ internal class Timeline : TemplatedControl
     private ScrollViewer? _timelineKeyFramesScroll;
     private ScrollViewer? _timelineHeaderScroll;
     private Control? extendingElement;
+    private Rectangle _selectionRectangle;
     
     private Vector clickPos;
+    
+    private bool shouldClearNextSelection = true;
+    private KeyFrameViewModel clickedKeyFrame;
+    private bool dragged;
+    private int dragStartFrame;
+    
+    public event PropertyChangedEventHandler? PropertyChanged;
 
     static Timeline()
     {
@@ -148,20 +167,102 @@ internal class Timeline : TemplatedControl
         _playTimer =
             new DispatcherTimer(DispatcherPriority.Render) { Interval = TimeSpan.FromMilliseconds(1000f / Fps) };
         _playTimer.Tick += PlayTimerOnTick;
-        SelectKeyFrameCommand = new RelayCommand<KeyFrameViewModel>(keyFrame =>
+        PressedKeyFrameCommand = new RelayCommand<PointerPressedEventArgs>((e) =>
+        {
+            shouldClearNextSelection = !e.KeyModifiers.HasFlag(KeyModifiers.Control);
+            KeyFrame target = null;
+            if (e.Source is Control obj)
+            {
+                if(obj is KeyFrame frame) target = frame;
+                else if (obj.TemplatedParent is KeyFrame keyFrame) target = keyFrame;
+            }
+            
+            e.Pointer.Capture(target);
+            clickedKeyFrame = target.Item;
+            dragStartFrame = MousePosToFrame(e);
+            e.Handled = true;
+        });
+        ClearSelectedKeyFramesCommand = new RelayCommand<KeyFrameViewModel>((keyFrame) =>
+        {
+            ClearSelectedKeyFrames();
+        });
+        DraggedKeyFrameCommand = new RelayCommand<PointerEventArgs>((e) =>
         {
-            SelectedKeyFrame = keyFrame;
+            if(clickedKeyFrame == null) return;
+            
+            int frameUnderMouse = MousePosToFrame(e);
+            int delta = frameUnderMouse - dragStartFrame;
+
+            if (delta != 0)
+            {
+                if (!clickedKeyFrame.IsSelected)
+                {
+                    SelectKeyFrame(clickedKeyFrame);
+                }
+                
+                dragged = true;
+                if (DragAllSelectedKeyFrames(delta))
+                {
+                    dragStartFrame += delta;
+                }
+            }
+        });
+        ReleasedKeyFrameCommand = new RelayCommand<KeyFrameViewModel>((e) =>
+        {
+            if (!dragged)
+            {
+                SelectKeyFrame(e, shouldClearNextSelection);
+                shouldClearNextSelection = true;
+            }
+            else
+            {
+                EndDragging();
+            }
+            
+            dragged = false;
+            clickedKeyFrame = null;
         });
     }
 
-    public void Play()
+    public void SelectKeyFrame(KeyFrameViewModel? keyFrame, bool clearSelection = true)
     {
-        IsPlaying = true;
+        if (clearSelection)
+        {
+            ClearSelectedKeyFrames();
+        }
+
+        keyFrame?.Document.AnimationDataViewModel.AddSelectedKeyFrame(keyFrame.Id);
+    }
+
+    private void ClearSelectedKeyFrames()
+    {
+        foreach (var keyFrame in SelectedKeyFrames)
+        {
+            keyFrame.Document.AnimationDataViewModel.RemoveSelectedKeyFrame(keyFrame.Id);
+        }
     }
 
-    public void Pause()
+    public bool DragAllSelectedKeyFrames(int delta)
     {
-        IsPlaying = false;
+        bool canDrag = SelectedKeyFrames.All(x => x.StartFrameBindable + delta >= 0);
+        if (!canDrag)
+        {
+            return false;
+        }
+        
+        Guid[] ids = SelectedKeyFrames.Select(x => x.Id).ToArray();
+        
+        ChangeKeyFramesLengthCommand.Execute((ids, delta, false));
+        return true;
+    }
+    
+    public void EndDragging()
+    {
+        if (dragged)
+        {
+            ChangeKeyFramesLengthCommand.Execute((SelectedKeyFrames.Select(x => x.Id).ToArray(), 0, true));
+        }
+        clickedKeyFrame = null;
     }
 
     protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
@@ -181,6 +282,8 @@ internal class Timeline : TemplatedControl
 
         _timelineKeyFramesScroll = e.NameScope.Find<ScrollViewer>("PART_TimelineKeyFramesScroll");
         _timelineHeaderScroll = e.NameScope.Find<ScrollViewer>("PART_TimelineHeaderScroll");
+        
+        _selectionRectangle = e.NameScope.Find<Rectangle>("PART_SelectionRectangle");
 
         _timelineKeyFramesScroll.ScrollChanged += TimelineKeyFramesScrollOnScrollChanged;
         _contentGrid.PointerPressed += ContentOnPointerPressed;
@@ -260,6 +363,7 @@ internal class Timeline : TemplatedControl
 
         if (towardsFrame * MarginMultiplier > KeyFrames.FrameCount)
         {
+            // 50 is a magic number I found working ok, for bigger frames it is a bit too big, maybe find a better way to calculate this?
             extendingElement.Margin = new Thickness(newOffsetX * 50, 0, 0, 0);
         }
         else
@@ -284,20 +388,30 @@ internal class Timeline : TemplatedControl
         {
             return;
         }
+        
+        var mouseButton = e.GetMouseButton(content);
 
-        if (e.GetMouseButton(content) == MouseButton.Middle)
+        if (mouseButton == MouseButton.Left)
+        {
+            _selectionRectangle.IsVisible = true;
+            _selectionRectangle.Width = 0;
+            _selectionRectangle.Height = 0;
+            
+        }
+        else if (mouseButton == MouseButton.Middle)
         {
             Cursor = new Cursor(StandardCursorType.SizeAll);
             e.Pointer.Capture(content);
-            clickPos = e.GetPosition(content);
 
             if (_timelineKeyFramesScroll.ScrollBarMaximum.X == ScrollOffset.X)
             {
                 extendingElement.Margin = new Thickness(_timelineKeyFramesScroll.Viewport.Width, 0, 0, 0);
             }
             
-            e.Handled = true;
         }
+        
+        clickPos = e.GetPosition(content);
+        e.Handled = true;
     }
     
     private void ContentOnPointerMoved(object? sender, PointerEventArgs e)
@@ -307,20 +421,41 @@ internal class Timeline : TemplatedControl
             return;
         }
 
-        if (e.GetCurrentPoint(content).Properties.IsMiddleButtonPressed)
+        if (e.GetCurrentPoint(content).Properties.IsLeftButtonPressed)
         {
-            double deltaX = clickPos.X - e.GetPosition(content).X;
-            double deltaY = clickPos.Y - e.GetPosition(content).Y;
-            double newOffsetX = ScrollOffset.X + deltaX;
-            double newOffsetY = ScrollOffset.Y + deltaY;
-            newOffsetX = Math.Clamp(newOffsetX, 0, _timelineKeyFramesScroll.ScrollBarMaximum.X);
-            newOffsetY = Math.Clamp(newOffsetY, 0, _timelineKeyFramesScroll.ScrollBarMaximum.Y);
-            ScrollOffset = new Vector(newOffsetX, newOffsetY);
-            
-            extendingElement.Margin += new Thickness(deltaX, 0, 0, 0);
+            HandleMoveSelection(e, content);
+        }
+        else if (e.GetCurrentPoint(content).Properties.IsMiddleButtonPressed)
+        {
+            HandleTimelinePan(e, content);
         }
     }
-    
+
+    private void HandleTimelinePan(PointerEventArgs e, Grid content)
+    {
+        double deltaX = clickPos.X - e.GetPosition(content).X;
+        double deltaY = clickPos.Y - e.GetPosition(content).Y;
+        double newOffsetX = ScrollOffset.X + deltaX;
+        double newOffsetY = ScrollOffset.Y + deltaY;
+        newOffsetX = Math.Clamp(newOffsetX, 0, _timelineKeyFramesScroll.ScrollBarMaximum.X);
+        newOffsetY = Math.Clamp(newOffsetY, 0, _timelineKeyFramesScroll.ScrollBarMaximum.Y);
+        ScrollOffset = new Vector(newOffsetX, newOffsetY);
+            
+        extendingElement.Margin += new Thickness(deltaX, 0, 0, 0);
+    }
+
+    private void HandleMoveSelection(PointerEventArgs e, Grid content)
+    {
+        double x = e.GetPosition(content).X;
+        double y = e.GetPosition(content).Y;
+        double width = x - clickPos.X;
+        double height = y - clickPos.Y;
+        _selectionRectangle.Width = Math.Abs(width);
+        _selectionRectangle.Height = Math.Abs(height);
+        Thickness margin = new Thickness(Math.Min(clickPos.X, x), Math.Min(clickPos.Y, y), 0, 0);
+        _selectionRectangle.Margin = margin;
+    }
+
     private void ContentOnPointerLost(object? sender, PointerCaptureLostEventArgs e)
     {
         if (e.Source is not Grid content)
@@ -329,6 +464,7 @@ internal class Timeline : TemplatedControl
         }
 
         Cursor = new Cursor(StandardCursorType.Arrow);
+        _selectionRectangle.IsVisible = false;
     }
 
     private int MousePosToFrame(PointerEventArgs e, bool round = true)
@@ -398,14 +534,29 @@ internal class Timeline : TemplatedControl
 
     private void KeyFrames_KeyFrameAdded(KeyFrameViewModel keyFrame)
     {
-        SelectedKeyFrame = keyFrame;
+        keyFrame.PropertyChanged += KeyFrameOnPropertyChanged;
+        PropertyChanged(this, new PropertyChangedEventArgs(nameof(SelectedKeyFrames)));
     }
 
     private void KeyFrames_KeyFrameRemoved(KeyFrameViewModel keyFrame)
     {
-        if (SelectedKeyFrame == keyFrame)
+        if (SelectedKeyFrames.Contains(keyFrame))
         {
-            SelectedKeyFrame = null;
+            keyFrame.Document.AnimationDataViewModel.RemoveSelectedKeyFrame(keyFrame.Id);
+            keyFrame.PropertyChanged -= KeyFrameOnPropertyChanged;
+        }
+        
+        PropertyChanged(this, new PropertyChangedEventArgs(nameof(SelectedKeyFrames)));
+    }
+    
+    private void KeyFrameOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (sender is KeyFrameViewModel keyFrame)
+        {
+            if (e.PropertyName == nameof(KeyFrameViewModel.IsSelected))
+            {
+                PropertyChanged(this, new PropertyChangedEventArgs(nameof(SelectedKeyFrames)));
+            }
         }
     }
 }

+ 2 - 1
src/PixiEditor.AvaloniaUI/Views/Dock/TimelineDockView.axaml

@@ -17,5 +17,6 @@
         ActiveFrame="{Binding DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.ActiveFrameBindable, Mode=TwoWay}"
         NewKeyFrameCommand="{xaml:Command PixiEditor.Animation.CreateRasterKeyFrame}"
         DuplicateKeyFrameCommand="{xaml:Command PixiEditor.Animation.DuplicateRasterKeyFrame}"
-        DeleteKeyFrameCommand="{xaml:Command PixiEditor.Animation.DeleteKeyFrame, UseProvided=True}"/>
+        DeleteKeyFrameCommand="{xaml:Command PixiEditor.Animation.DeleteKeyFrames, UseProvided=True}"
+        ChangeKeyFramesLengthCommand="{xaml:Command PixiEditor.Animation.ChangeKeyFramesStartPos, UseProvided=True}"/>
 </UserControl>

+ 87 - 0
src/PixiEditor.ChangeableDocument/Changes/Animation/KeyFramesStartPos_UpdateableChange.cs

@@ -0,0 +1,87 @@
+using PixiEditor.ChangeableDocument.Changeables.Animations;
+using PixiEditor.ChangeableDocument.ChangeInfos.Animation;
+
+namespace PixiEditor.ChangeableDocument.Changes.Animation;
+
+internal class KeyFramesStartPos_UpdateableChange : UpdateableChange
+{
+    public Guid[] KeyFramesGuid { get;  }
+    public int Delta { get; set; }
+    
+    private Dictionary<Guid, int> originalStartFrames = new();
+    
+    [GenerateUpdateableChangeActions]
+    public KeyFramesStartPos_UpdateableChange(List<Guid> keyFramesGuid, int delta)
+    {
+        KeyFramesGuid = keyFramesGuid.ToArray();
+        Delta = 0;
+    }
+
+    [UpdateChangeMethod]
+    public void Update(int delta)
+    {
+        Delta += delta;
+    }
+    
+    public override bool InitializeAndValidate(Document target)
+    {
+        foreach (Guid keyFrameGuid in KeyFramesGuid)
+        {
+            if (target.AnimationData.TryFindKeyFrame(keyFrameGuid, out KeyFrame keyFrame))
+            {
+                originalStartFrames[keyFrameGuid] = keyFrame.StartFrame;
+            }
+            else
+            {
+                return false;
+            }
+        }
+
+        return true;
+    }
+    
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        List<IChangeInfo> changes = new();
+        foreach (Guid keyFrameGuid in KeyFramesGuid)
+        {
+            target.AnimationData.TryFindKeyFrame(keyFrameGuid, out KeyFrame keyFrame);
+            keyFrame.StartFrame = originalStartFrames[keyFrameGuid] + Delta;
+            changes.Add(new KeyFrameLength_ChangeInfo(keyFrameGuid, keyFrame.StartFrame, keyFrame.Duration));
+        }
+        
+        return changes;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        List<IChangeInfo> changes = new();
+        foreach (Guid keyFrameGuid in KeyFramesGuid)
+        {
+            target.AnimationData.TryFindKeyFrame(keyFrameGuid, out KeyFrame keyFrame);
+            keyFrame.StartFrame = originalStartFrames[keyFrameGuid] + Delta;
+            changes.Add(new KeyFrameLength_ChangeInfo(keyFrameGuid, keyFrame.StartFrame, keyFrame.Duration));
+        }
+        
+        ignoreInUndo = false;
+        return changes;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        List<IChangeInfo> changes = new();
+        foreach (Guid keyFrameGuid in KeyFramesGuid)
+        {
+            target.AnimationData.TryFindKeyFrame(keyFrameGuid, out KeyFrame keyFrame);
+            keyFrame.StartFrame = originalStartFrames[keyFrameGuid];
+            changes.Add(new KeyFrameLength_ChangeInfo(keyFrameGuid, keyFrame.StartFrame, keyFrame.Duration));
+        }
+        
+        return changes;
+    }
+    
+    public override bool IsMergeableWith(Change other)
+    {
+        return other is KeyFramesStartPos_UpdateableChange otherChange && otherChange.KeyFramesGuid.SequenceEqual(KeyFramesGuid);
+    }
+}

+ 1 - 0
src/PixiEditor.UI.Common/Accents/Base.axaml

@@ -13,6 +13,7 @@
             <Color x:Key="AccentColor">#B00022</Color>
             <Color x:Key="AccentHighColor">#c0334e</Color>
             <Color x:Key="ThemeAccent2Color">#5fad65</Color>
+            <Color x:Key="ThemeAccent2HighColor">#3dd276</Color>
 
             <Color x:Key="ThemeForegroundColor">#FFFFFF</Color>
             <Color x:Key="ThemeForegroundSecondaryColor">#B3FFFFFF</Color>