From 025fd326bd5eb3378e390eb682875bd319891fc3 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 12 Jan 2024 06:13:34 -0500
Subject: [PATCH] Click and drag on a sustain to edit it.

---
 assets                                        |   2 +-
 hmm.json                                      |   4 +-
 source/funkin/data/song/SongData.hx           |  36 +++-
 source/funkin/play/notes/SustainTrail.hx      |  32 +++-
 .../ui/debug/charting/ChartEditorState.hx     | 162 ++++++++++++++++--
 .../commands/ExtendNoteLengthCommand.hx       |  34 +++-
 .../components/ChartEditorHoldNoteSprite.hx   |  37 +++-
 .../ChartEditorSelectionSquareSprite.hx       |  21 ++-
 .../ChartEditorHoldNoteContextMenu.hx         |  43 +++++
 .../ChartEditorNoteContextMenu.hx             |   5 +
 .../handlers/ChartEditorContextMenuHandler.hx |  18 ++
 .../handlers/ChartEditorThemeHandler.hx       |   2 +-
 12 files changed, 358 insertions(+), 38 deletions(-)
 create mode 100644 source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx

diff --git a/assets b/assets
index b282f3431..7e31e86db 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit b282f3431c15b719222196813da98ab70839d3e5
+Subproject commit 7e31e86dbeec3df5076895dedc62a45cc14d66e1
diff --git a/hmm.json b/hmm.json
index d461edd24..8d05a7a2e 100644
--- a/hmm.json
+++ b/hmm.json
@@ -54,14 +54,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "5086e59e7551d775ed4d1fb0188e31de22d1312b",
+      "ref": "2561076c5abeee0a60f3a2a65a8ecb7832a6a62a",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "2b9cff727999b53ed292b1675ac1c9089ac77600",
+      "ref": "9c8ab039524086f5a8c8f35b9fb14538b5bfba5d",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 1a726254f..708881429 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -826,7 +826,13 @@ class SongNoteDataRaw implements ICloneable<SongNoteDataRaw>
   @:alias("l")
   @:default(0)
   @:optional
-  public var length:Float;
+  public var length(default, set):Float;
+
+  function set_length(value:Float):Float
+  {
+    _stepLength = null;
+    return length = value;
+  }
 
   /**
    * The kind of the note.
@@ -883,6 +889,11 @@ class SongNoteDataRaw implements ICloneable<SongNoteDataRaw>
     return _stepTime = Conductor.instance.getTimeInSteps(this.time);
   }
 
+  /**
+   * The length of the note, if applicable, in steps.
+   * Calculated from the length and the BPM.
+   * Cached for performance. Set to `null` to recalculate.
+   */
   @:jignored
   var _stepLength:Null<Float> = null;
 
@@ -907,9 +918,14 @@ class SongNoteDataRaw implements ICloneable<SongNoteDataRaw>
     }
     else
     {
-      var lengthMs:Float = Conductor.instance.getStepTimeInMs(value) - this.time;
+      var endStep:Float = getStepTime() + value;
+      var endMs:Float = Conductor.instance.getStepTimeInMs(endStep);
+      var lengthMs:Float = endMs - this.time;
+
       this.length = lengthMs;
     }
+
+    // Recalculate the step length next time it's requested.
     _stepLength = null;
   }
 
@@ -980,6 +996,10 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
   @:op(A == B)
   public function op_equals(other:SongNoteData):Bool
   {
+    // Handle the case where one value is null.
+    if (this == null) return other == null;
+    if (other == null) return false;
+
     if (this.kind == '')
     {
       if (other.kind != '' && other.kind != 'normal') return false;
@@ -995,6 +1015,10 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
   @:op(A != B)
   public function op_notEquals(other:SongNoteData):Bool
   {
+    // Handle the case where one value is null.
+    if (this == null) return other == null;
+    if (other == null) return false;
+
     if (this.kind == '')
     {
       if (other.kind != '' && other.kind != 'normal') return true;
@@ -1010,24 +1034,32 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
   @:op(A > B)
   public function op_greaterThan(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time > other.time;
   }
 
   @:op(A < B)
   public function op_lessThan(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time < other.time;
   }
 
   @:op(A >= B)
   public function op_greaterThanOrEquals(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time >= other.time;
   }
 
   @:op(A <= B)
   public function op_lessThanOrEquals(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time <= other.time;
   }
 
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index 7367b97af..4902afd49 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -82,6 +82,9 @@ class SustainTrail extends FlxSprite
 
   public var isPixel:Bool;
 
+  var graphicWidth:Float = 0;
+  var graphicHeight:Float = 0;
+
   /**
    * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?)
    * @param NoteData
@@ -110,8 +113,8 @@ class SustainTrail extends FlxSprite
     zoom *= 0.7;
 
     // CALCULATE SIZE
-    width = graphic.width / 8 * zoom; // amount of notes * 2
-    height = sustainHeight(sustainLength, getScrollSpeed());
+    graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2
+    graphicHeight = sustainHeight(sustainLength, getScrollSpeed());
     // instead of scrollSpeed, PlayState.SONG.speed
 
     flipY = Preferences.downscroll;
@@ -148,12 +151,21 @@ class SustainTrail extends FlxSprite
 
     if (sustainLength == s) return s;
 
-    height = sustainHeight(s, getScrollSpeed());
+    graphicHeight = sustainHeight(s, getScrollSpeed());
     this.sustainLength = s;
     updateClipping();
+    updateHitbox();
     return this.sustainLength;
   }
 
+  public override function updateHitbox():Void
+  {
+    width = graphicWidth;
+    height = graphicHeight;
+    offset.set(0, 0);
+    origin.set(width * 0.5, height * 0.5);
+  }
+
   /**
    * Sets up new vertex and UV data to clip the trail.
    * If flipY is true, top and bottom bounds swap places.
@@ -161,7 +173,7 @@ class SustainTrail extends FlxSprite
    */
   public function updateClipping(songTime:Float = 0):Void
   {
-    var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, height);
+    var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, graphicHeight);
     if (clipHeight <= 0.1)
     {
       visible = false;
@@ -178,10 +190,10 @@ class SustainTrail extends FlxSprite
     // ===HOLD VERTICES==
     // Top left
     vertices[0 * 2] = 0.0; // Inline with left side
-    vertices[0 * 2 + 1] = flipY ? clipHeight : height - clipHeight;
+    vertices[0 * 2 + 1] = flipY ? clipHeight : graphicHeight - clipHeight;
 
     // Top right
-    vertices[1 * 2] = width;
+    vertices[1 * 2] = graphicWidth;
     vertices[1 * 2 + 1] = vertices[0 * 2 + 1]; // Inline with top left vertex
 
     // Bottom left
@@ -197,7 +209,7 @@ class SustainTrail extends FlxSprite
     }
 
     // Bottom right
-    vertices[3 * 2] = width;
+    vertices[3 * 2] = graphicWidth;
     vertices[3 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex
 
     // ===HOLD UVs===
@@ -233,7 +245,7 @@ class SustainTrail extends FlxSprite
 
     // Bottom left
     vertices[6 * 2] = vertices[2 * 2]; // Inline with left side
-    vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (height + graphic.height * (bottomClip - endOffset) * zoom);
+    vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (graphicHeight + graphic.height * (bottomClip - endOffset) * zoom);
 
     // Bottom right
     vertices[7 * 2] = vertices[3 * 2]; // Inline with right side
@@ -277,6 +289,10 @@ class SustainTrail extends FlxSprite
       getScreenPosition(_point, camera).subtractPoint(offset);
       camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing);
     }
+
+    #if FLX_DEBUG
+    if (FlxG.debugger.drawDebug) drawDebug();
+    #end
   }
 
   public override function kill():Void
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 1773a84fe..33bba450f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -718,7 +718,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    * `null` if the user isn't currently placing a note.
    * As the user drags, we will update this note's sustain length, and finalize the note when they release.
    */
-  var currentPlaceNoteData:Null<SongNoteData> = null;
+  var currentPlaceNoteData(default, set):Null<SongNoteData> = null;
+
+  function set_currentPlaceNoteData(value:Null<SongNoteData>):Null<SongNoteData>
+  {
+    noteDisplayDirty = true;
+
+    return currentPlaceNoteData = value;
+  }
 
   // Note Movement
 
@@ -2270,7 +2277,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       bounds.height = MIN_HEIGHT;
     }
 
-    trace('Note preview viewport bounds: ' + bounds.toString());
+    // trace('Note preview viewport bounds: ' + bounds.toString());
 
     return bounds;
   }
@@ -3047,8 +3054,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       {
         if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue;
 
-        if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
+        if (holdNoteSprite.noteData == currentPlaceNoteData)
         {
+          // This hold note is for the note we are currently dragging.
+          // It will be displayed by gridGhostHoldNoteSprite instead.
+          holdNoteSprite.kill();
+        }
+        else if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
+        {
+          // This hold note is off-screen.
+          // Kill the hold note sprite and recycle it.
           holdNoteSprite.kill();
         }
         else if (!currentSongChartNoteData.fastContains(holdNoteSprite.noteData) || holdNoteSprite.noteData.length == 0)
@@ -3066,7 +3081,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         else
         {
           displayedHoldNoteData.push(holdNoteSprite.noteData);
-          // Update the event sprite's position.
+          // Update the event sprite's height and position.
+          // var holdNoteHeight = holdNoteSprite.noteData.getStepLength() * GRID_SIZE;
+          // holdNoteSprite.setHeightDirectly(holdNoteHeight);
           holdNoteSprite.updateHoldNotePosition(renderedNotes);
         }
       }
@@ -3144,7 +3161,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         noteSprite.updateNotePosition(renderedNotes);
 
         // Add hold notes that are now visible (and not already displayed).
-        if (noteSprite.noteData != null && noteSprite.noteData.length > 0 && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1)
+        if (noteSprite.noteData != null
+          && noteSprite.noteData.length > 0
+          && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1
+          && noteSprite.noteData != currentPlaceNoteData)
         {
           var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(() -> new ChartEditorHoldNoteSprite(this));
           // trace('Creating new HoldNote... (${renderedHoldNotes.members.length})');
@@ -3157,6 +3177,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           holdNoteSprite.setHeightDirectly(noteLengthPixels);
 
           holdNoteSprite.updateHoldNotePosition(renderedHoldNotes);
+
+          trace(holdNoteSprite.x + ', ' + holdNoteSprite.y + ', ' + holdNoteSprite.width + ', ' + holdNoteSprite.height);
         }
       }
 
@@ -3195,6 +3217,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         // Is the note a hold note?
         if (noteData == null || noteData.length <= 0) continue;
 
+        // Is the note the one we are dragging? If so, ghostHoldNoteSprite will handle it.
+        if (noteData == currentPlaceNoteData) continue;
+
         // Is the hold note rendered already?
         if (displayedHoldNoteData.indexOf(noteData) != -1) continue;
 
@@ -3284,7 +3309,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           selectionSquare.x = noteSprite.x;
           selectionSquare.y = noteSprite.y;
           selectionSquare.width = GRID_SIZE;
-          selectionSquare.height = GRID_SIZE;
+
+          var stepLength = noteSprite.noteData.getStepLength();
+          selectionSquare.height = (stepLength <= 0) ? GRID_SIZE : ((stepLength + 1) * GRID_SIZE);
         }
       }
 
@@ -3563,6 +3590,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite);
 
+      var overlapsRenderedNotes:Bool = FlxG.mouse.overlaps(renderedNotes);
+      var overlapsRenderedHoldNotes:Bool = FlxG.mouse.overlaps(renderedHoldNotes);
+      var overlapsRenderedEvents:Bool = FlxG.mouse.overlaps(renderedEvents);
+
       // Cursor position relative to the grid.
       var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x;
       var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y;
@@ -3804,12 +3835,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 return event.alive && FlxG.mouse.overlaps(event);
               });
             }
+            var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null;
+            if (highlightedNote == null && highlightedEvent == null)
+            {
+              highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool {
+                return holdNote.alive && FlxG.mouse.overlaps(holdNote);
+              });
+            }
 
             if (FlxG.keys.pressed.CONTROL)
             {
               if (highlightedNote != null && highlightedNote.noteData != null)
               {
-                // TODO: Handle the case of clicking on a sustain piece.
                 // Control click to select/deselect an individual note.
                 if (isNoteSelected(highlightedNote.noteData))
                 {
@@ -3832,6 +3869,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                   performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
                 }
               }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                // Control click to select/deselect an individual note.
+                if (isNoteSelected(highlightedNote.noteData))
+                {
+                  performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+              }
               else
               {
                 // Do nothing if you control-clicked on an empty space.
@@ -3849,6 +3898,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 // Click an event to select it.
                 performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
               }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                // Click a hold note to select it.
+                performCommand(new SetItemSelectionCommand([highlightedHoldNote.noteData], [], currentNoteSelection, currentEventSelection));
+              }
               else
               {
                 // Click on an empty space to deselect everything.
@@ -4001,7 +4055,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         var dragLengthMs:Float = dragLengthSteps * Conductor.instance.stepLengthMs;
         var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE;
 
-        if (gridGhostNote != null && gridGhostNote.noteData != null && gridGhostHoldNote != null)
+        if (gridGhostHoldNote != null)
         {
           if (dragLengthSteps > 0)
           {
@@ -4014,8 +4068,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             }
 
             gridGhostHoldNote.visible = true;
-            gridGhostHoldNote.noteData = gridGhostNote.noteData;
-            gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
+            gridGhostHoldNote.noteData = currentPlaceNoteData;
+            gridGhostHoldNote.noteDirection = currentPlaceNoteData.getDirection();
 
             gridGhostHoldNote.setHeightDirectly(dragLengthPixels, true);
 
@@ -4036,6 +4090,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             // Apply the new length.
             performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs));
           }
+          else
+          {
+            // Apply the new (zero) length if we are changing the length.
+            if (currentPlaceNoteData.length > 0)
+            {
+              this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
+              performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, 0));
+            }
+          }
 
           // Finished dragging. Release the note.
           currentPlaceNoteData = null;
@@ -4068,6 +4131,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 return event.alive && FlxG.mouse.overlaps(event);
               });
             }
+            var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null;
+            if (highlightedNote == null && highlightedEvent == null)
+            {
+              highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool {
+                // If holdNote.alive is false, the holdNote is dead and awaiting recycling.
+                return holdNote.alive && FlxG.mouse.overlaps(holdNote);
+              });
+            }
 
             if (FlxG.keys.pressed.CONTROL)
             {
@@ -4094,6 +4165,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                   performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
                 }
               }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                if (isNoteSelected(highlightedNote.noteData))
+                {
+                  performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+              }
               else
               {
                 // Do nothing when control clicking nothing.
@@ -4127,6 +4209,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                   performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
                 }
               }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                // Clicked a hold note, start dragging TO EXTEND NOTE LENGTH.
+                currentPlaceNoteData = highlightedHoldNote.noteData;
+              }
               else
               {
                 // Click a blank space to place a note and select it.
@@ -4176,6 +4263,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
               return event.alive && FlxG.mouse.overlaps(event);
             });
           }
+          var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null;
+          if (highlightedNote == null && highlightedEvent == null)
+          {
+            highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool {
+              // If holdNote.alive is false, the holdNote is dead and awaiting recycling.
+              return holdNote.alive && FlxG.mouse.overlaps(holdNote);
+            });
+          }
 
           if (highlightedNote != null && highlightedNote.noteData != null)
           {
@@ -4227,13 +4322,40 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
               performCommand(new RemoveEventsCommand([highlightedEvent.eventData]));
             }
           }
+          else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+          {
+            if (FlxG.keys.pressed.SHIFT)
+            {
+              // Shift + Right click opens the context menu.
+              // If we are clicking a large selection, open the Selection context menu, otherwise open the Note context menu.
+              var isHighlightedNoteSelected:Bool = isNoteSelected(highlightedHoldNote.noteData);
+              var useSingleNoteContextMenu:Bool = (!isHighlightedNoteSelected)
+                || (isHighlightedNoteSelected && currentNoteSelection.length == 1);
+              // Show the context menu connected to the note.
+              if (useSingleNoteContextMenu)
+              {
+                this.openHoldNoteContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY, highlightedHoldNote.noteData);
+              }
+              else
+              {
+                this.openSelectionContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY);
+              }
+            }
+            else
+            {
+              // Right click removes hold from the note.
+              this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
+              performCommand(new ExtendNoteLengthCommand(highlightedHoldNote.noteData, 0));
+            }
+          }
           else
           {
             // Right clicked on nothing.
           }
         }
 
-        var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null;
+        var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null || overlapsRenderedNotes || overlapsRenderedHoldNotes
+          || overlapsRenderedEvents;
         // Handle grid cursor.
         if (!isCursorOverHaxeUI && overlapsGrid && !isOrWillSelect && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
         {
@@ -4324,6 +4446,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             {
               targetCursorMode = Crosshair;
             }
+            else if (overlapsRenderedNotes)
+            {
+              targetCursorMode = Pointer;
+            }
+            else if (overlapsRenderedHoldNotes)
+            {
+              targetCursorMode = Pointer;
+            }
+            else if (overlapsRenderedEvents)
+            {
+              targetCursorMode = Pointer;
+            }
             else if (overlapsGrid)
             {
               targetCursorMode = Cell;
@@ -5042,7 +5176,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()";
 
     // FlxG.bitmapLog.add(selectionSquareBitmap, "selectionSquareBitmap");
-    var result = new ChartEditorSelectionSquareSprite();
+    var result = new ChartEditorSelectionSquareSprite(this);
     result.loadGraphic(selectionSquareBitmap);
     return result;
   }
@@ -5371,7 +5505,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   /**
    * HAXEUI FUNCTIONS
    */
-  // ====================
+  // ==================
 
   /**
    * Set the currently selected item in the Difficulty tree view to the node representing the current difficulty.
@@ -5462,7 +5596,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   /**
    * STATIC FUNCTIONS
    */
-  // ====================
+  // ==================
 
   function handleNotePreview():Void
   {
diff --git a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
index 47da0dde5..62ffe63b9 100644
--- a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
@@ -13,17 +13,25 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
   var note:SongNoteData;
   var oldLength:Float;
   var newLength:Float;
+  var unit:Unit;
 
-  public function new(note:SongNoteData, newLength:Float)
+  public function new(note:SongNoteData, newLength:Float, unit:Unit = MILLISECONDS)
   {
     this.note = note;
     this.oldLength = note.length;
     this.newLength = newLength;
+    this.unit = unit;
   }
 
   public function execute(state:ChartEditorState):Void
   {
-    note.length = newLength;
+    switch (unit)
+    {
+      case MILLISECONDS:
+        this.note.length = newLength;
+      case STEPS:
+        this.note.setStepLength(newLength);
+    }
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -36,7 +44,8 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
   {
     state.playSound(Paths.sound('chartingSounds/undo'));
 
-    note.length = oldLength;
+    // Always use milliseconds for undoing
+    this.note.length = oldLength;
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -47,6 +56,23 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
 
   public function toString():String
   {
-    return 'Extend Note Length';
+    if (oldLength == 0)
+    {
+      return 'Add Hold to Note';
+    }
+    else if (newLength == 0)
+    {
+      return 'Remove Hold from Note';
+    }
+    else
+    {
+      return 'Extend Hold Note Length';
+    }
   }
 }
+
+enum Unit
+{
+  MILLISECONDS;
+  STEPS;
+}
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
index e5971db08..a7764907c 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
@@ -39,6 +39,17 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     setup();
   }
 
+  public override function updateHitbox():Void
+  {
+    // Expand the clickable hitbox to the full column width, then nudge to the left to re-center it.
+    width = ChartEditorState.GRID_SIZE;
+    height = graphicHeight;
+
+    var xOffset = (ChartEditorState.GRID_SIZE - graphicWidth) / 2;
+    offset.set(-xOffset, 0);
+    origin.set(width * 0.5, height * 0.5);
+  }
+
   /**
    * Set the height directly, to a value in pixels.
    * @param h The desired height in pixels.
@@ -52,6 +63,23 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     fullSustainLength = sustainLength;
   }
 
+  /**
+   * Call this to override how debug bounding boxes are drawn for this sprite.
+   */
+  public override function drawDebugOnCamera(camera:flixel.FlxCamera):Void
+  {
+    if (!camera.visible || !camera.exists || !isOnScreen(camera)) return;
+
+    var rect = getBoundingBox(camera);
+    trace('hold note bounding box: ' + rect.x + ', ' + rect.y + ', ' + rect.width + ', ' + rect.height);
+
+    var gfx = beginDrawDebug(camera);
+    debugBoundingBoxColor = 0xffFF66FF;
+    gfx.lineStyle(2, color, 0.5); // thickness, color, alpha
+    gfx.drawRect(rect.x, rect.y, rect.width, rect.height);
+    endDrawDebug(camera);
+  }
+
   function setup():Void
   {
     strumTime = 999999999;
@@ -60,7 +88,9 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     active = true;
     visible = true;
     alpha = 1.0;
-    width = graphic.width / 8 * zoom; // amount of notes * 2
+    graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2
+
+    updateHitbox();
   }
 
   public override function revive():Void
@@ -154,7 +184,7 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     }
 
     this.x += ChartEditorState.GRID_SIZE / 2;
-    this.x -= this.width / 2;
+    this.x -= this.graphicWidth / 2;
 
     this.y += ChartEditorState.GRID_SIZE / 2;
 
@@ -163,5 +193,8 @@ class ChartEditorHoldNoteSprite extends SustainTrail
       this.x += origin.x;
       this.y += origin.y;
     }
+
+    // Account for expanded clickable hitbox.
+    this.x += this.offset.x;
   }
 }
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
index 8f7c4aaec..14266b71a 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
@@ -1,20 +1,33 @@
 package funkin.ui.debug.charting.components;
 
+import flixel.addons.display.FlxSliceSprite;
 import flixel.FlxSprite;
-import funkin.data.song.SongData.SongNoteData;
+import flixel.math.FlxRect;
 import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.ui.debug.charting.handlers.ChartEditorThemeHandler;
 
 /**
  * A sprite that can be used to display a square over a selected note or event in the chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
-class ChartEditorSelectionSquareSprite extends FlxSprite
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorSelectionSquareSprite extends FlxSliceSprite
 {
   public var noteData:Null<SongNoteData>;
   public var eventData:Null<SongEventData>;
 
-  public function new()
+  public function new(chartEditorState:ChartEditorState)
   {
-    super();
+    super(chartEditorState.selectionSquareBitmap,
+      new FlxRect(ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH
+        + 4, ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH
+        + 4,
+        ChartEditorState.GRID_SIZE
+        - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8),
+        ChartEditorState.GRID_SIZE
+        - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8)),
+      32, 32);
   }
 }
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx
new file mode 100644
index 000000000..9f58d2f03
--- /dev/null
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx
@@ -0,0 +1,43 @@
+package funkin.ui.debug.charting.contextmenus;
+
+import haxe.ui.containers.menus.Menu;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.core.Screen;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.ui.debug.charting.commands.FlipNotesCommand;
+import funkin.ui.debug.charting.commands.RemoveNotesCommand;
+import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/hold-note.xml"))
+class ChartEditorHoldNoteContextMenu extends ChartEditorBaseContextMenu
+{
+  var contextmenuFlip:MenuItem;
+  var contextmenuDelete:MenuItem;
+
+  var data:SongNoteData;
+
+  public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongNoteData)
+  {
+    super(chartEditorState2, xPos2, yPos2);
+    this.data = data;
+
+    initialize();
+  }
+
+  function initialize():Void
+  {
+    // NOTE: Remember to use commands here to ensure undo/redo works properly
+    contextmenuFlip.onClick = function(_) {
+      chartEditorState.performCommand(new FlipNotesCommand([data]));
+    }
+
+    contextmenuRemoveHold.onClick = function(_) {
+      chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 0));
+    }
+
+    contextmenuDelete.onClick = function(_) {
+      chartEditorState.performCommand(new RemoveNotesCommand([data]));
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx
index 4bfab27e8..66bf6f3ee 100644
--- a/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx
@@ -6,6 +6,7 @@ import haxe.ui.core.Screen;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.ui.debug.charting.commands.FlipNotesCommand;
 import funkin.ui.debug.charting.commands.RemoveNotesCommand;
+import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand;
 
 @:access(funkin.ui.debug.charting.ChartEditorState)
 @:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/note.xml"))
@@ -31,6 +32,10 @@ class ChartEditorNoteContextMenu extends ChartEditorBaseContextMenu
       chartEditorState.performCommand(new FlipNotesCommand([data]));
     }
 
+    contextmenuAddHold.onClick = function(_) {
+      chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 4, STEPS));
+    }
+
     contextmenuDelete.onClick = function(_) {
       chartEditorState.performCommand(new RemoveNotesCommand([data]));
     }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx
index b914f4149..c1eea5379 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx
@@ -2,6 +2,7 @@ package funkin.ui.debug.charting.handlers;
 
 import funkin.ui.debug.charting.contextmenus.ChartEditorDefaultContextMenu;
 import funkin.ui.debug.charting.contextmenus.ChartEditorEventContextMenu;
+import funkin.ui.debug.charting.contextmenus.ChartEditorHoldNoteContextMenu;
 import funkin.ui.debug.charting.contextmenus.ChartEditorNoteContextMenu;
 import funkin.ui.debug.charting.contextmenus.ChartEditorSelectionContextMenu;
 import haxe.ui.containers.menus.Menu;
@@ -23,16 +24,33 @@ class ChartEditorContextMenuHandler
     displayMenu(state, new ChartEditorDefaultContextMenu(state, xPos, yPos));
   }
 
+  /**
+   * Opened when shift+right-clicking a selection of multiple items.
+   */
   public static function openSelectionContextMenu(state:ChartEditorState, xPos:Float, yPos:Float)
   {
     displayMenu(state, new ChartEditorSelectionContextMenu(state, xPos, yPos));
   }
 
+  /**
+   * Opened when shift+right-clicking a single note.
+   */
   public static function openNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData)
   {
     displayMenu(state, new ChartEditorNoteContextMenu(state, xPos, yPos, data));
   }
 
+  /**
+   * Opened when shift+right-clicking a single hold note.
+   */
+  public static function openHoldNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData)
+  {
+    displayMenu(state, new ChartEditorHoldNoteContextMenu(state, xPos, yPos, data));
+  }
+
+  /**
+   * Opened when shift+right-clicking a single event.
+   */
   public static function openEventContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongEventData)
   {
     displayMenu(state, new ChartEditorEventContextMenu(state, xPos, yPos, data));
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
index d3aef4bfd..98bb5c2c8 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
@@ -52,7 +52,7 @@ class ChartEditorThemeHandler
   // Border on the square highlighting selected notes.
   static final SELECTION_SQUARE_BORDER_COLOR_LIGHT:FlxColor = 0xFF339933;
   static final SELECTION_SQUARE_BORDER_COLOR_DARK:FlxColor = 0xFF339933;
-  static final SELECTION_SQUARE_BORDER_WIDTH:Int = 1;
+  public static final SELECTION_SQUARE_BORDER_WIDTH:Int = 1;
 
   // Fill on the square highlighting selected notes.
   // Make sure this is transparent so you can see the notes underneath.