diff --git a/.vscode/settings.json b/.vscode/settings.json
index 80d2bf76a..92d49c3d4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -71,7 +71,7 @@
   "files.eol": "\n",
 
   "haxe.displayPort": "auto",
-  "haxe.enableCompilationServer": true,
+  "haxe.enableCompilationServer": false,
   "haxe.displayServer": {
     "arguments": ["-v"]
   },
@@ -97,15 +97,35 @@
       "args": ["-debug"]
     },
     {
-      "label": "Windows / Debug (DEBUG ASSETS)",
-      "target": "windows",
-      "args": ["-debug", "-DDEBUG_ASSETS"]
-    },
-    {
-      "label": "Windows / Debug (ANIMATE)",
+      "label": "Windows / Debug (FlxAnimate Test)",
       "target": "windows",
       "args": ["-debug", "-DANIMATE"]
     },
+    {
+      "label": "Windows / Debug (Straight to Freeplay)",
+      "target": "windows",
+      "args": ["-debug", "-DFREEPLAY"]
+    },
+    {
+      "label": "Windows / Debug (Straight to Play - Bopeebo Normal)",
+      "target": "windows",
+      "args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"]
+    },
+    {
+      "label": "Windows / Debug (Straight to Chart Editor)",
+      "target": "windows",
+      "args": ["-debug", "-DCHARTING"]
+    },
+    {
+      "label": "Windows / Debug (Straight to Animation Editor)",
+      "target": "windows",
+      "args": ["-debug", "-DANIMDEBUG"]
+    },
+    {
+      "label": "Windows / Debug (Latency Test)",
+      "target": "windows",
+      "args": ["-debug", "-DLATENCY"]
+    },
     {
       "label": "HTML5 / Debug",
       "target": "html5",
diff --git a/assets b/assets
index ef79a6cf1..be9d790af 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit ef79a6cf1ae3dcbd86a5b798f8117a6c692c0156
+Subproject commit be9d790af9c6f1f5e3afc7aed2b1d5c21823bc20
diff --git a/docs/style-guide.md b/docs/style-guide.md
index 71ae844c4..1131cca2b 100644
--- a/docs/style-guide.md
+++ b/docs/style-guide.md
@@ -24,7 +24,7 @@ Example:
 ```
 /**
  * Finds the largest deviation from the desired time inside this VoicesGroup.
- * 
+ *
  * @param targetTime	The time to check against.
  * 						If none is provided, it checks the time of all members against the first member of this VoicesGroup.
  * @return The largest deviation from the target time found.
@@ -52,3 +52,10 @@ import sys.io.File;
 #end
 ```
 
+## Argument Formatting
+
+[Optional arguments](https://haxe.org/manual/types-function-optional-arguments.html) and [default arguments](https://haxe.org/manual/types-function-default-values.html) should be mutually exclusive and not used together!
+
+For example, `myFunction(?input:Int)` should be used if you want the argument to be a `Null<Int>` whose value is `null` if no value is passed, and `myFunction(input:Int = 0)` should be used if you want the argument to be an `Int`, whose value is `0` if no value is passed.
+
+Using both at the same time is considered valid by Haxe, but `myFunction(?input:Int = 0)` results in a `Null<Int>` whose value defaults to 0 anyway, so it's never null, but it's annotated as nullable! The biggest consequence of this is that it makes null safety more annoying.
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index ecfa32eb3..5299a3aa0 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -266,6 +266,10 @@ class InitState extends FlxState
       return;
     }
 
+    // Load and cache the song's charts.
+    // TODO: Do this in the loading state.
+    songData.cacheCharts(true);
+
     LoadingState.loadAndSwitchState(new funkin.play.PlayState(
       {
         targetSong: songData,
diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 9a986a8b5..9861c48c7 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -117,7 +117,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   public function stepHit():Bool
   {
-    var event = new SongTimeScriptEvent(ScriptEvent.SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep);
 
     dispatchEvent(event);
 
@@ -128,7 +128,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   public function beatHit():Bool
   {
-    var event = new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep);
 
     dispatchEvent(event);
 
@@ -148,7 +148,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   override function startOutro(onComplete:() -> Void):Void
   {
-    var event = new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_BEGIN, null, true);
+    var event = new StateChangeScriptEvent(STATE_CHANGE_BEGIN, null, true);
 
     dispatchEvent(event);
 
@@ -164,7 +164,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   public override function openSubState(targetSubState:FlxSubState):Void
   {
-    var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_BEGIN, targetSubState, true);
+    var event = new SubStateScriptEvent(SUBSTATE_OPEN_BEGIN, targetSubState, true);
 
     dispatchEvent(event);
 
@@ -175,12 +175,12 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   function onOpenSubStateComplete(targetState:FlxSubState):Void
   {
-    dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_END, targetState, true));
+    dispatchEvent(new SubStateScriptEvent(SUBSTATE_OPEN_END, targetState, true));
   }
 
   public override function closeSubState():Void
   {
-    var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_BEGIN, this.subState, true);
+    var event = new SubStateScriptEvent(SUBSTATE_CLOSE_BEGIN, this.subState, true);
 
     dispatchEvent(event);
 
@@ -191,6 +191,6 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   function onCloseSubStateComplete(targetState:FlxSubState):Void
   {
-    dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_END, targetState, true));
+    dispatchEvent(new SubStateScriptEvent(SUBSTATE_CLOSE_END, targetState, true));
   }
 }
diff --git a/source/funkin/MusicBeatSubState.hx b/source/funkin/MusicBeatSubState.hx
index 31d1bd14c..53fe19bdd 100644
--- a/source/funkin/MusicBeatSubState.hx
+++ b/source/funkin/MusicBeatSubState.hx
@@ -96,7 +96,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl
    */
   public function stepHit():Bool
   {
-    var event:ScriptEvent = new SongTimeScriptEvent(ScriptEvent.SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event:ScriptEvent = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep);
 
     dispatchEvent(event);
 
@@ -112,7 +112,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl
    */
   public function beatHit():Bool
   {
-    var event:ScriptEvent = new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event:ScriptEvent = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep);
 
     dispatchEvent(event);
 
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 88993e519..783f52a64 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -447,7 +447,7 @@ class SongChartData
   }
 }
 
-class SongEventData
+class SongEventDataRaw
 {
   /**
    * The timestamp of the event. The timestamp is in the format of the song's time format.
@@ -503,40 +503,57 @@ class SongEventData
 
     return _stepTime = Conductor.getTimeInSteps(this.time);
   }
+}
+
+/**
+ * Wrap SongEventData in an abstract so we can overload operators.
+ */
+@:forward(time, event, value, activated, getStepTime)
+abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw
+{
+  public function new(time:Float, event:String, value:Dynamic = null)
+  {
+    this = new SongEventDataRaw(time, event, value);
+  }
 
   public inline function getDynamic(key:String):Null<Dynamic>
   {
-    return value == null ? null : Reflect.field(value, key);
+    return this.value == null ? null : Reflect.field(this.value, key);
   }
 
   public inline function getBool(key:String):Null<Bool>
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
   public inline function getInt(key:String):Null<Int>
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
   public inline function getFloat(key:String):Null<Float>
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
   public inline function getString(key:String):String
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
   public inline function getArray(key:String):Array<Dynamic>
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
   public inline function getBoolArray(key:String):Array<Bool>
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    return this.value == null ? null : cast Reflect.field(this.value, key);
+  }
+
+  public function clone():SongEventData
+  {
+    return new SongEventData(this.time, this.event, this.value);
   }
 
   @:op(A == B)
@@ -584,7 +601,7 @@ class SongEventData
   }
 }
 
-class SongNoteData
+class SongNoteDataRaw
 {
   /**
    * The timestamp of the note. The timestamp is in the format of the song's time format.
@@ -655,6 +672,48 @@ class SongNoteData
     return _stepTime = Conductor.getTimeInSteps(this.time);
   }
 
+  @:jignored
+  var _stepLength:Null<Float> = null;
+
+  /**
+   * @param force Set to `true` to force recalculation (good after BPM changes)
+   * @return The length of the hold note in steps, or `0` if this is not a hold note.
+   */
+  public function getStepLength(force = false):Float
+  {
+    if (this.length <= 0) return 0.0;
+
+    if (_stepLength != null && !force) return _stepLength;
+
+    return _stepLength = Conductor.getTimeInSteps(this.time + this.length) - getStepTime();
+  }
+
+  public function setStepLength(value:Float):Void
+  {
+    if (value <= 0)
+    {
+      this.length = 0.0;
+    }
+    else
+    {
+      var lengthMs:Float = Conductor.getStepTimeInMs(value) - this.time;
+      this.length = lengthMs;
+    }
+    _stepLength = null;
+  }
+}
+
+/**
+ * Wrap SongNoteData in an abstract so we can overload operators.
+ */
+@:forward
+abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
+{
+  public function new(time:Float, data:Int, length:Float = 0, kind:String = '')
+  {
+    this = new SongNoteDataRaw(time, data, length, kind);
+  }
+
   /**
    * The direction of the note, if applicable.
    * Strips the strumline index from the data.
@@ -668,7 +727,12 @@ class SongNoteData
 
   public function getDirectionName(strumlineSize:Int = 4):String
   {
-    switch (this.data % strumlineSize)
+    return SongNoteData.buildDirectionName(this.data, strumlineSize);
+  }
+
+  public static function buildDirectionName(data:Int, strumlineSize:Int = 4):String
+  {
+    switch (data % strumlineSize)
     {
       case 0:
         return 'Left';
@@ -705,36 +769,6 @@ class SongNoteData
     return getStrumlineIndex(strumlineSize) == 0;
   }
 
-  @:jignored
-  var _stepLength:Null<Float> = null;
-
-  /**
-   * @param force Set to `true` to force recalculation (good after BPM changes)
-   * @return The length of the hold note in steps, or `0` if this is not a hold note.
-   */
-  public function getStepLength(force = false):Float
-  {
-    if (this.length <= 0) return 0.0;
-
-    if (_stepLength != null && !force) return _stepLength;
-
-    return _stepLength = Conductor.getTimeInSteps(this.time + this.length) - getStepTime();
-  }
-
-  public function setStepLength(value:Float):Void
-  {
-    if (value <= 0)
-    {
-      this.length = 0.0;
-    }
-    else
-    {
-      var lengthMs:Float = Conductor.getStepTimeInMs(value) - this.time;
-      this.length = lengthMs;
-    }
-    _stepLength = null;
-  }
-
   @:jignored
   public var isHoldNote(get, never):Bool;
 
@@ -797,6 +831,11 @@ class SongNoteData
     return this.time <= other.time;
   }
 
+  public function clone():SongNoteData
+  {
+    return new SongNoteData(this.time, this.data, this.length, this.kind);
+  }
+
   /**
    * Produces a string representation suitable for debugging.
    */
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index 3ff3943c6..984af18fa 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -66,8 +66,14 @@ class SongDataUtils
 
     var result = notes.filter(function(note:SongNoteData):Bool {
       for (x in subtrahend)
+      {
+        // The currently iterated note is in the subtrahend array.
         // SongNoteData's == operation has been overridden so that this will work.
-        if (x == note) return false;
+        if (x == note)
+        {
+          return false;
+        }
+      }
 
       return true;
     });
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 1c3a0fdb4..8c7124da0 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -12,7 +12,9 @@ using Lambda;
 using StringTools;
 using funkin.util.tools.ArraySortTools;
 using funkin.util.tools.ArrayTools;
+using funkin.util.tools.FloatTools;
 using funkin.util.tools.Int64Tools;
+using funkin.util.tools.IntTools;
 using funkin.util.tools.IteratorTools;
 using funkin.util.tools.MapTools;
 using funkin.util.tools.SongEventDataArrayTools;
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 586a6206c..18f934aee 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -10,265 +10,12 @@ import funkin.play.notes.NoteDirection;
 import openfl.events.EventType;
 import openfl.events.KeyboardEvent;
 
-typedef ScriptEventType = EventType<ScriptEvent>;
-
 /**
  * This is a base class for all events that are issued to scripted classes.
  * It can be used to identify the type of event called, store data, and cancel event propagation.
  */
 class ScriptEvent
 {
-  /**
-   * Called when the relevant object is created.
-   * Keep in mind that the constructor may be called before the object is needed,
-   * for the purposes of caching data or otherwise.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final CREATE:ScriptEventType = 'CREATE';
-
-  /**
-   * Called when the relevant object is destroyed.
-   * This should perform relevant cleanup to ensure good performance.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final DESTROY:ScriptEventType = 'DESTROY';
-
-  /**
-   * Called when the relevent object is added to the game state.
-   * This assumes all data is loaded and ready to go.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final ADDED:ScriptEventType = 'ADDED';
-
-  /**
-   * Called during the update function.
-   * This is called every frame, so be careful!
-   *
-   * This event is not cancelable.
-   */
-  public static inline final UPDATE:ScriptEventType = 'UPDATE';
-
-  /**
-   * Called when the player moves to pause the game.
-   *
-   * This event IS cancelable! Canceling the event will prevent the game from pausing.
-   */
-  public static inline final PAUSE:ScriptEventType = 'PAUSE';
-
-  /**
-   * Called when the player moves to unpause the game while paused.
-   *
-   * This event IS cancelable! Canceling the event will prevent the game from resuming.
-   */
-  public static inline final RESUME:ScriptEventType = 'RESUME';
-
-  /**
-   * Called once per step in the song. This happens 4 times per measure.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SONG_BEAT_HIT:ScriptEventType = 'BEAT_HIT';
-
-  /**
-   * Called once per step in the song. This happens 16 times per measure.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SONG_STEP_HIT:ScriptEventType = 'STEP_HIT';
-
-  /**
-   * Called when a character hits a note.
-   * Important information such as judgement/timing, note data, player/opponent, etc. are all provided.
-   *
-   * This event IS cancelable! Canceling this event prevents the note from being hit,
-   *   and will likely result in a miss later.
-   */
-  public static inline final NOTE_HIT:ScriptEventType = 'NOTE_HIT';
-
-  /**
-   * Called when a character misses a note.
-   * Important information such as note data, player/opponent, etc. are all provided.
-   *
-   * This event IS cancelable! Canceling this event prevents the note from being considered missed,
-   *   avoiding a combo break and lost health.
-   */
-  public static inline final NOTE_MISS:ScriptEventType = 'NOTE_MISS';
-
-  /**
-   * Called when a character presses a note when there was none there, causing them to lose health.
-   * Important information such as direction pressed, etc. are all provided.
-   *
-   * This event IS cancelable! Canceling this event prevents the note from being considered missed,
-   *   avoiding lost health/score and preventing the miss animation.
-   */
-  public static inline final NOTE_GHOST_MISS:ScriptEventType = 'NOTE_GHOST_MISS';
-
-  /**
-   * Called when a song event is reached in the chart.
-   *
-   * This event IS cancelable! Cancelling this event prevents the event from being triggered,
-   *   thus blocking its normal functionality.
-   */
-  public static inline final SONG_EVENT:ScriptEventType = 'SONG_EVENT';
-
-  /**
-   * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SONG_START:ScriptEventType = 'SONG_START';
-
-  /**
-   * Called when the song ends. This happens as the instrumental and vocals end.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SONG_END:ScriptEventType = 'SONG_END';
-
-  /**
-   * Called when the countdown begins. This occurs before the song starts.
-   *
-   * This event IS cancelable! Canceling this event will prevent the countdown from starting.
-   * - The song will not start until you call Countdown.performCountdown() later.
-   * - Note that calling performCountdown() will trigger this event again, so be sure to add logic to ignore it.
-   */
-  public static inline final COUNTDOWN_START:ScriptEventType = 'COUNTDOWN_START';
-
-  /**
-   * Called when a step of the countdown happens.
-   * Includes information about what step of the countdown was hit.
-   *
-   * This event IS cancelable! Canceling this event will pause the countdown.
-   * - The countdown will not resume until you call PlayState.resumeCountdown().
-   */
-  public static inline final COUNTDOWN_STEP:ScriptEventType = 'COUNTDOWN_STEP';
-
-  /**
-   * Called when the countdown is done but just before the song starts.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final COUNTDOWN_END:ScriptEventType = 'COUNTDOWN_END';
-
-  /**
-   * Called before the game over screen triggers and the death animation plays.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final GAME_OVER:ScriptEventType = 'GAME_OVER';
-
-  /**
-   * Called after the player presses a key to restart the game.
-   * This can happen from the pause menu or the game over screen.
-   *
-   * This event IS cancelable! Canceling this event will prevent the game from restarting.
-   */
-  public static inline final SONG_RETRY:ScriptEventType = 'SONG_RETRY';
-
-  /**
-   * Called when the player pushes down any key on the keyboard.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final KEY_DOWN:ScriptEventType = 'KEY_DOWN';
-
-  /**
-   * Called when the player releases a key on the keyboard.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final KEY_UP:ScriptEventType = 'KEY_UP';
-
-  /**
-   * Called when the game has finished loading the notes from JSON.
-   * This allows modders to mutate the notes before they are used in the song.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SONG_LOADED:ScriptEventType = 'SONG_LOADED';
-
-  /**
-   * Called when the game is about to switch the current FlxState.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final STATE_CHANGE_BEGIN:ScriptEventType = 'STATE_CHANGE_BEGIN';
-
-  /**
-   * Called when the game has finished switching the current FlxState.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final STATE_CHANGE_END:ScriptEventType = 'STATE_CHANGE_END';
-
-  /**
-   * Called when the game is about to open a new FlxSubState.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SUBSTATE_OPEN_BEGIN:ScriptEventType = 'SUBSTATE_OPEN_BEGIN';
-
-  /**
-   * Called when the game has finished opening a new FlxSubState.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SUBSTATE_OPEN_END:ScriptEventType = 'SUBSTATE_OPEN_END';
-
-  /**
-   * Called when the game is about to close the current FlxSubState.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SUBSTATE_CLOSE_BEGIN:ScriptEventType = 'SUBSTATE_CLOSE_BEGIN';
-
-  /**
-   * Called when the game has finished closing the current FlxSubState.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final SUBSTATE_CLOSE_END:ScriptEventType = 'SUBSTATE_CLOSE_END';
-
-  /**
-   * Called when the game starts a conversation.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final DIALOGUE_START:ScriptEventType = 'DIALOGUE_START';
-
-  /**
-   * Called to display the next line of conversation.
-   *
-   * This event IS cancelable! Canceling this event will prevent the conversation from moving to the next line.
-   * - This event is called when the conversation starts, or when the user presses ACCEPT to advance the conversation.
-   */
-  public static inline final DIALOGUE_LINE:ScriptEventType = 'DIALOGUE_LINE';
-
-  /**
-   * Called to skip scrolling the current line of conversation.
-   *
-   * This event IS cancelable! Canceling this event will prevent the conversation from skipping to the next line.
-   * - This event is called when the user presses ACCEPT to advance the conversation while it is already advancing.
-   */
-  public static inline final DIALOGUE_COMPLETE_LINE:ScriptEventType = 'DIALOGUE_COMPLETE_LINE';
-
-  /**
-   * Called to skip the conversation.
-   *
-   * This event IS cancelable! Canceling this event will prevent the conversation from skipping.
-   */
-  public static inline final DIALOGUE_SKIP:ScriptEventType = 'DIALOGUE_SKIP';
-
-  /**
-   * Called when the game ends a conversation.
-   *
-   * This event is not cancelable.
-   */
-  public static inline final DIALOGUE_END:ScriptEventType = 'DIALOGUE_END';
-
   /**
    * If true, the behavior associated with this event can be prevented.
    * For example, cancelling COUNTDOWN_START should prevent the countdown from starting,
@@ -411,7 +158,7 @@ class GhostMissNoteScriptEvent extends ScriptEvent
 
   public function new(dir:NoteDirection, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void
   {
-    super(ScriptEvent.NOTE_GHOST_MISS, true);
+    super(NOTE_GHOST_MISS, true);
     this.dir = dir;
     this.hasPossibleNotes = hasPossibleNotes;
     this.healthChange = healthChange;
@@ -439,7 +186,7 @@ class SongEventScriptEvent extends ScriptEvent
 
   public function new(event:funkin.data.song.SongData.SongEventData):Void
   {
-    super(ScriptEvent.SONG_EVENT, true);
+    super(SONG_EVENT, true);
     this.event = event;
   }
 
@@ -462,7 +209,7 @@ class UpdateScriptEvent extends ScriptEvent
 
   public function new(elapsed:Float):Void
   {
-    super(ScriptEvent.UPDATE, false);
+    super(UPDATE, false);
     this.elapsed = elapsed;
   }
 
@@ -591,7 +338,7 @@ class SongLoadScriptEvent extends ScriptEvent
 
   public function new(id:String, difficulty:String, notes:Array<SongNoteData>):Void
   {
-    super(ScriptEvent.SONG_LOADED, false);
+    super(SONG_LOADED, false);
     this.id = id;
     this.difficulty = difficulty;
     this.notes = notes;
@@ -660,7 +407,7 @@ class PauseScriptEvent extends ScriptEvent
 
   public function new(gitaroo:Bool):Void
   {
-    super(ScriptEvent.PAUSE, true);
+    super(PAUSE, true);
     this.gitaroo = gitaroo;
   }
 }
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index 5e3e32a46..f5d797ea4 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -23,15 +23,16 @@ class ScriptEventDispatcher
     // IScriptedClass
     switch (event.type)
     {
-      case ScriptEvent.CREATE:
+      case CREATE:
         target.onCreate(event);
         return;
-      case ScriptEvent.DESTROY:
+      case DESTROY:
         target.onDestroy(event);
         return;
-      case ScriptEvent.UPDATE:
+      case UPDATE:
         target.onUpdate(cast event);
         return;
+      default: // Continue;
     }
 
     if (Std.isOfType(target, IStateStageProp))
@@ -39,9 +40,10 @@ class ScriptEventDispatcher
       var t:IStateStageProp = cast(target, IStateStageProp);
       switch (event.type)
       {
-        case ScriptEvent.ADDED:
+        case ADDED:
           t.onAdd(cast event);
           return;
+        default: // Continue;
       }
     }
 
@@ -50,21 +52,22 @@ class ScriptEventDispatcher
       var t:IDialogueScriptedClass = cast(target, IDialogueScriptedClass);
       switch (event.type)
       {
-        case ScriptEvent.DIALOGUE_START:
+        case DIALOGUE_START:
           t.onDialogueStart(cast event);
           return;
-        case ScriptEvent.DIALOGUE_LINE:
+        case DIALOGUE_LINE:
           t.onDialogueLine(cast event);
           return;
-        case ScriptEvent.DIALOGUE_COMPLETE_LINE:
+        case DIALOGUE_COMPLETE_LINE:
           t.onDialogueCompleteLine(cast event);
           return;
-        case ScriptEvent.DIALOGUE_SKIP:
+        case DIALOGUE_SKIP:
           t.onDialogueSkip(cast event);
           return;
-        case ScriptEvent.DIALOGUE_END:
+        case DIALOGUE_END:
           t.onDialogueEnd(cast event);
           return;
+        default: // Continue;
       }
     }
 
@@ -73,54 +76,55 @@ class ScriptEventDispatcher
       var t:IPlayStateScriptedClass = cast(target, IPlayStateScriptedClass);
       switch (event.type)
       {
-        case ScriptEvent.NOTE_HIT:
+        case NOTE_HIT:
           t.onNoteHit(cast event);
           return;
-        case ScriptEvent.NOTE_MISS:
+        case NOTE_MISS:
           t.onNoteMiss(cast event);
           return;
-        case ScriptEvent.NOTE_GHOST_MISS:
+        case NOTE_GHOST_MISS:
           t.onNoteGhostMiss(cast event);
           return;
-        case ScriptEvent.SONG_BEAT_HIT:
+        case SONG_BEAT_HIT:
           t.onBeatHit(cast event);
           return;
-        case ScriptEvent.SONG_STEP_HIT:
+        case SONG_STEP_HIT:
           t.onStepHit(cast event);
           return;
-        case ScriptEvent.SONG_START:
+        case SONG_START:
           t.onSongStart(event);
           return;
-        case ScriptEvent.SONG_END:
+        case SONG_END:
           t.onSongEnd(event);
           return;
-        case ScriptEvent.SONG_RETRY:
+        case SONG_RETRY:
           t.onSongRetry(event);
           return;
-        case ScriptEvent.GAME_OVER:
+        case GAME_OVER:
           t.onGameOver(event);
           return;
-        case ScriptEvent.PAUSE:
+        case PAUSE:
           t.onPause(cast event);
           return;
-        case ScriptEvent.RESUME:
+        case RESUME:
           t.onResume(event);
           return;
-        case ScriptEvent.SONG_EVENT:
+        case SONG_EVENT:
           t.onSongEvent(cast event);
           return;
-        case ScriptEvent.COUNTDOWN_START:
+        case COUNTDOWN_START:
           t.onCountdownStart(cast event);
           return;
-        case ScriptEvent.COUNTDOWN_STEP:
+        case COUNTDOWN_STEP:
           t.onCountdownStep(cast event);
           return;
-        case ScriptEvent.COUNTDOWN_END:
+        case COUNTDOWN_END:
           t.onCountdownEnd(cast event);
           return;
-        case ScriptEvent.SONG_LOADED:
+        case SONG_LOADED:
           t.onSongLoaded(cast event);
           return;
+        default: // Continue;
       }
     }
 
@@ -129,24 +133,25 @@ class ScriptEventDispatcher
       var t = cast(target, IStateChangingScriptedClass);
       switch (event.type)
       {
-        case ScriptEvent.STATE_CHANGE_BEGIN:
+        case STATE_CHANGE_BEGIN:
           t.onStateChangeBegin(cast event);
           return;
-        case ScriptEvent.STATE_CHANGE_END:
+        case STATE_CHANGE_END:
           t.onStateChangeEnd(cast event);
           return;
-        case ScriptEvent.SUBSTATE_OPEN_BEGIN:
+        case SUBSTATE_OPEN_BEGIN:
           t.onSubStateOpenBegin(cast event);
           return;
-        case ScriptEvent.SUBSTATE_OPEN_END:
+        case SUBSTATE_OPEN_END:
           t.onSubStateOpenEnd(cast event);
           return;
-        case ScriptEvent.SUBSTATE_CLOSE_BEGIN:
+        case SUBSTATE_CLOSE_BEGIN:
           t.onSubStateCloseBegin(cast event);
           return;
-        case ScriptEvent.SUBSTATE_CLOSE_END:
+        case SUBSTATE_CLOSE_END:
           t.onSubStateCloseEnd(cast event);
           return;
+        default: // Continue;
       }
     }
     else
diff --git a/source/funkin/modding/events/ScriptEventType.hx b/source/funkin/modding/events/ScriptEventType.hx
new file mode 100644
index 000000000..e06b5ad24
--- /dev/null
+++ b/source/funkin/modding/events/ScriptEventType.hx
@@ -0,0 +1,271 @@
+package funkin.modding.events;
+
+enum abstract ScriptEventType(String) from String to String
+{
+  /**
+   * Called when the relevant object is created.
+   * Keep in mind that the constructor may be called before the object is needed,
+   * for the purposes of caching data or otherwise.
+   *
+   * This event is not cancelable.
+   */
+  var CREATE = 'CREATE';
+
+  /**
+   * Called when the relevant object is destroyed.
+   * This should perform relevant cleanup to ensure good performance.
+   *
+   * This event is not cancelable.
+   */
+  var DESTROY = 'DESTROY';
+
+  /**
+   * Called when the relevent object is added to the game state.
+   * This assumes all data is loaded and ready to go.
+   *
+   * This event is not cancelable.
+   */
+  var ADDED = 'ADDED';
+
+  /**
+   * Called during the update function.
+   * This is called every frame, so be careful!
+   *
+   * This event is not cancelable.
+   */
+  var UPDATE = 'UPDATE';
+
+  /**
+   * Called when the player moves to pause the game.
+   *
+   * This event IS cancelable! Canceling the event will prevent the game from pausing.
+   */
+  var PAUSE = 'PAUSE';
+
+  /**
+   * Called when the player moves to unpause the game while paused.
+   *
+   * This event IS cancelable! Canceling the event will prevent the game from resuming.
+   */
+  var RESUME = 'RESUME';
+
+  /**
+   * Called once per step in the song. This happens 4 times per measure.
+   *
+   * This event is not cancelable.
+   */
+  var SONG_BEAT_HIT = 'BEAT_HIT';
+
+  /**
+   * Called once per step in the song. This happens 16 times per measure.
+   *
+   * This event is not cancelable.
+   */
+  var SONG_STEP_HIT = 'STEP_HIT';
+
+  /**
+   * Called when a character hits a note.
+   * Important information such as judgement/timing, note data, player/opponent, etc. are all provided.
+   *
+   * This event IS cancelable! Canceling this event prevents the note from being hit,
+   *   and will likely result in a miss later.
+   */
+  var NOTE_HIT = 'NOTE_HIT';
+
+  /**
+   * Called when a character misses a note.
+   * Important information such as note data, player/opponent, etc. are all provided.
+   *
+   * This event IS cancelable! Canceling this event prevents the note from being considered missed,
+   *   avoiding a combo break and lost health.
+   */
+  var NOTE_MISS = 'NOTE_MISS';
+
+  /**
+   * Called when a character presses a note when there was none there, causing them to lose health.
+   * Important information such as direction pressed, etc. are all provided.
+   *
+   * This event IS cancelable! Canceling this event prevents the note from being considered missed,
+   *   avoiding lost health/score and preventing the miss animation.
+   */
+  var NOTE_GHOST_MISS = 'NOTE_GHOST_MISS';
+
+  /**
+   * Called when a song event is reached in the chart.
+   *
+   * This event IS cancelable! Cancelling this event prevents the event from being triggered,
+   *   thus blocking its normal functionality.
+   */
+  var SONG_EVENT = 'SONG_EVENT';
+
+  /**
+   * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin.
+   *
+   * This event is not cancelable.
+   */
+  var SONG_START = 'SONG_START';
+
+  /**
+   * Called when the song ends. This happens as the instrumental and vocals end.
+   *
+   * This event is not cancelable.
+   */
+  var SONG_END = 'SONG_END';
+
+  /**
+   * Called when the countdown begins. This occurs before the song starts.
+   *
+   * This event IS cancelable! Canceling this event will prevent the countdown from starting.
+   * - The song will not start until you call Countdown.performCountdown() later.
+   * - Note that calling performCountdown() will trigger this event again, so be sure to add logic to ignore it.
+   */
+  var COUNTDOWN_START = 'COUNTDOWN_START';
+
+  /**
+   * Called when a step of the countdown happens.
+   * Includes information about what step of the countdown was hit.
+   *
+   * This event IS cancelable! Canceling this event will pause the countdown.
+   * - The countdown will not resume until you call PlayState.resumeCountdown().
+   */
+  var COUNTDOWN_STEP = 'COUNTDOWN_STEP';
+
+  /**
+   * Called when the countdown is done but just before the song starts.
+   *
+   * This event is not cancelable.
+   */
+  var COUNTDOWN_END = 'COUNTDOWN_END';
+
+  /**
+   * Called before the game over screen triggers and the death animation plays.
+   *
+   * This event is not cancelable.
+   */
+  var GAME_OVER = 'GAME_OVER';
+
+  /**
+   * Called after the player presses a key to restart the game.
+   * This can happen from the pause menu or the game over screen.
+   *
+   * This event IS cancelable! Canceling this event will prevent the game from restarting.
+   */
+  var SONG_RETRY = 'SONG_RETRY';
+
+  /**
+   * Called when the player pushes down any key on the keyboard.
+   *
+   * This event is not cancelable.
+   */
+  var KEY_DOWN = 'KEY_DOWN';
+
+  /**
+   * Called when the player releases a key on the keyboard.
+   *
+   * This event is not cancelable.
+   */
+  var KEY_UP = 'KEY_UP';
+
+  /**
+   * Called when the game has finished loading the notes from JSON.
+   * This allows modders to mutate the notes before they are used in the song.
+   *
+   * This event is not cancelable.
+   */
+  var SONG_LOADED = 'SONG_LOADED';
+
+  /**
+   * Called when the game is about to switch the current FlxState.
+   *
+   * This event is not cancelable.
+   */
+  var STATE_CHANGE_BEGIN = 'STATE_CHANGE_BEGIN';
+
+  /**
+   * Called when the game has finished switching the current FlxState.
+   *
+   * This event is not cancelable.
+   */
+  var STATE_CHANGE_END = 'STATE_CHANGE_END';
+
+  /**
+   * Called when the game is about to open a new FlxSubState.
+   *
+   * This event is not cancelable.
+   */
+  var SUBSTATE_OPEN_BEGIN = 'SUBSTATE_OPEN_BEGIN';
+
+  /**
+   * Called when the game has finished opening a new FlxSubState.
+   *
+   * This event is not cancelable.
+   */
+  var SUBSTATE_OPEN_END = 'SUBSTATE_OPEN_END';
+
+  /**
+   * Called when the game is about to close the current FlxSubState.
+   *
+   * This event is not cancelable.
+   */
+  var SUBSTATE_CLOSE_BEGIN = 'SUBSTATE_CLOSE_BEGIN';
+
+  /**
+   * Called when the game has finished closing the current FlxSubState.
+   *
+   * This event is not cancelable.
+   */
+  var SUBSTATE_CLOSE_END = 'SUBSTATE_CLOSE_END';
+
+  /**
+   * Called when the game starts a conversation.
+   *
+   * This event is not cancelable.
+   */
+  var DIALOGUE_START = 'DIALOGUE_START';
+
+  /**
+   * Called to display the next line of conversation.
+   *
+   * This event IS cancelable! Canceling this event will prevent the conversation from moving to the next line.
+   * - This event is called when the conversation starts, or when the user presses ACCEPT to advance the conversation.
+   */
+  var DIALOGUE_LINE = 'DIALOGUE_LINE';
+
+  /**
+   * Called to skip scrolling the current line of conversation.
+   *
+   * This event IS cancelable! Canceling this event will prevent the conversation from skipping to the next line.
+   * - This event is called when the user presses ACCEPT to advance the conversation while it is already advancing.
+   */
+  var DIALOGUE_COMPLETE_LINE = 'DIALOGUE_COMPLETE_LINE';
+
+  /**
+   * Called to skip the conversation.
+   *
+   * This event IS cancelable! Canceling this event will prevent the conversation from skipping.
+   */
+  var DIALOGUE_SKIP = 'DIALOGUE_SKIP';
+
+  /**
+   * Called when the game ends a conversation.
+   *
+   * This event is not cancelable.
+   */
+  var DIALOGUE_END = 'DIALOGUE_END';
+
+  /**
+   * Allow for comparing `ScriptEventType` to `String`.
+   */
+  @:op(A == B) private static inline function equals(a:ScriptEventType, b:String):Bool
+  {
+    return (a : String) == b;
+  }
+
+  /**
+   * Allow for comparing `ScriptEventType` to `String`.
+   */
+  @:op(A != B) private static inline function notEquals(a:ScriptEventType, b:String):Bool
+  {
+    return (a : String) != b;
+  }
+}
diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx
index 3cc7b7984..4711e7419 100644
--- a/source/funkin/modding/module/ModuleHandler.hx
+++ b/source/funkin/modding/module/ModuleHandler.hx
@@ -55,7 +55,7 @@ class ModuleHandler
 
   static function onStateSwitchComplete():Void
   {
-    callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true));
+    callEvent(new StateChangeScriptEvent(STATE_CHANGE_END, FlxG.state, true));
   }
 
   static function addToModuleCache(module:Module):Void
@@ -119,7 +119,7 @@ class ModuleHandler
   {
     if (moduleCache != null)
     {
-      var event = new ScriptEvent(ScriptEvent.DESTROY, false);
+      var event = new ScriptEvent(DESTROY, false);
 
       // Note: Ignore stopPropagation()
       for (key => value in moduleCache)
@@ -148,6 +148,6 @@ class ModuleHandler
 
   public static inline function callOnCreate():Void
   {
-    callEvent(new ScriptEvent(ScriptEvent.CREATE, false));
+    callEvent(new ScriptEvent(CREATE, false));
   }
 }
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 9796c7161..d23574ce2 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -43,7 +43,7 @@ class Countdown
     Conductor.update(PlayState.instance.startTimestamp + Conductor.beatLengthMs * -5);
     // Handle onBeatHit events manually
     // @:privateAccess
-    // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
+    // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(SONG_BEAT_HIT, 0, 0));
 
     // The timer function gets called based on the beat of the song.
     countdownTimer = new FlxTimer();
@@ -59,7 +59,7 @@ class Countdown
 
       // onBeatHit events are now properly dispatched by the Conductor even at negative timestamps,
       // so calling this is no longer necessary.
-      // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
+      // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(SONG_BEAT_HIT, 0, 0));
 
       // Countdown graphic.
       showCountdownGraphic(countdownStep, isPixelStyle);
@@ -94,11 +94,11 @@ class Countdown
     switch (index)
     {
       case BEFORE:
-        event = new CountdownScriptEvent(ScriptEvent.COUNTDOWN_START, index);
+        event = new CountdownScriptEvent(COUNTDOWN_START, index);
       case THREE | TWO | ONE | GO: // I didn't know you could use `|` in a switch/case block!
-        event = new CountdownScriptEvent(ScriptEvent.COUNTDOWN_STEP, index);
+        event = new CountdownScriptEvent(COUNTDOWN_STEP, index);
       case AFTER:
-        event = new CountdownScriptEvent(ScriptEvent.COUNTDOWN_END, index, false);
+        event = new CountdownScriptEvent(COUNTDOWN_END, index, false);
       default:
         return true;
     }
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 1d3480efe..4542b9f98 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -682,7 +682,7 @@ class PlayState extends MusicBeatSubState
     {
       if (!assertChartExists()) return;
 
-      dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY));
+      dispatchEvent(new ScriptEvent(SONG_RETRY));
 
       resetCamera();
 
@@ -867,7 +867,7 @@ class PlayState extends MusicBeatSubState
 
         deathCounter += 1;
 
-        dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER));
+        dispatchEvent(new ScriptEvent(GAME_OVER));
 
         // Disable updates, preventing animations in the background from playing.
         persistentUpdate = false;
@@ -994,7 +994,7 @@ class PlayState extends MusicBeatSubState
   {
     if (Std.isOfType(subState, PauseSubState))
     {
-      var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true);
+      var event:ScriptEvent = new ScriptEvent(RESUME, true);
 
       dispatchEvent(event);
 
@@ -1097,7 +1097,7 @@ class PlayState extends MusicBeatSubState
     if (this.currentStage != null)
     {
       remove(currentStage);
-      var event:ScriptEvent = new ScriptEvent(ScriptEvent.DESTROY, false);
+      var event:ScriptEvent = new ScriptEvent(DESTROY, false);
       ScriptEventDispatcher.callEvent(currentStage, event);
       currentStage = null;
     }
@@ -1116,7 +1116,7 @@ class PlayState extends MusicBeatSubState
 
     super.debug_refreshModules();
 
-    var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
+    var event:ScriptEvent = new ScriptEvent(CREATE, false);
     ScriptEventDispatcher.callEvent(currentSong, event);
   }
 
@@ -1332,7 +1332,7 @@ class PlayState extends MusicBeatSubState
     if (currentStage != null)
     {
       // Actually create and position the sprites.
-      var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
+      var event:ScriptEvent = new ScriptEvent(CREATE, false);
       ScriptEventDispatcher.callEvent(currentStage, event);
 
       // Apply camera zoom level from stage data.
@@ -1640,7 +1640,7 @@ class PlayState extends MusicBeatSubState
     add(currentConversation);
     refresh();
 
-    var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
+    var event:ScriptEvent = new ScriptEvent(CREATE, false);
     ScriptEventDispatcher.callEvent(currentConversation, event);
   }
 
@@ -1664,7 +1664,7 @@ class PlayState extends MusicBeatSubState
    */
   function startSong():Void
   {
-    dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START));
+    dispatchEvent(new ScriptEvent(SONG_START));
 
     startingSong = false;
 
@@ -1783,7 +1783,7 @@ class PlayState extends MusicBeatSubState
 
         // Call an event to allow canceling the note hit.
         // NOTE: This is what handles the character animations!
-        var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, 0, true);
+        var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, note, 0, true);
         dispatchEvent(event);
 
         // Calling event.cancelEvent() skips all the other logic! Neat!
@@ -1872,7 +1872,7 @@ class PlayState extends MusicBeatSubState
       {
         // Call an event to allow canceling the note miss.
         // NOTE: This is what handles the character animations!
-        var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, 0, true);
+        var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, 0, true);
         dispatchEvent(event);
 
         // Calling event.cancelEvent() skips all the other logic! Neat!
@@ -2021,7 +2021,7 @@ class PlayState extends MusicBeatSubState
 
   function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void
   {
-    var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
+    var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, note, Highscore.tallies.combo + 1, true);
     dispatchEvent(event);
 
     // Calling event.cancelEvent() skips all the other logic! Neat!
@@ -2053,7 +2053,7 @@ class PlayState extends MusicBeatSubState
     // a MISS is when you let a note scroll past you!!
     Highscore.tallies.missed++;
 
-    var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, Highscore.tallies.combo, true);
+    var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, Highscore.tallies.combo, true);
     dispatchEvent(event);
     // Calling event.cancelEvent() skips all the other logic! Neat!
     if (event.eventCanceled) return;
@@ -2385,7 +2385,7 @@ class PlayState extends MusicBeatSubState
    */
   function endSong():Void
   {
-    dispatchEvent(new ScriptEvent(ScriptEvent.SONG_END));
+    dispatchEvent(new ScriptEvent(SONG_END));
 
     #if sys
     // spitter for ravy, teehee!!
@@ -2593,7 +2593,7 @@ class PlayState extends MusicBeatSubState
     {
       remove(currentStage);
       currentStage.kill();
-      dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false));
+      dispatchEvent(new ScriptEvent(DESTROY, false));
       currentStage = null;
     }
 
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 8be9f25c7..abe8bf992 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -254,7 +254,7 @@ class CharacterDataParser
     char.debug = debug;
 
     // Call onCreate only in the fetchCharacter() function, not at application initialization.
-    ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE));
+    ScriptEventDispatcher.callEvent(char, new ScriptEvent(CREATE));
 
     return char;
   }
diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx
index 2b7db381c..46acf3f37 100644
--- a/source/funkin/play/cutscene/dialogue/Conversation.hx
+++ b/source/funkin/play/cutscene/dialogue/Conversation.hx
@@ -120,7 +120,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
     this.alpha = 1.0;
 
     // Start the dialogue.
-    dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_START, this, false));
+    dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, false));
   }
 
   function setupMusic():Void
@@ -214,7 +214,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
       return;
     }
 
-    ScriptEventDispatcher.callEvent(nextSpeaker, new ScriptEvent(ScriptEvent.CREATE, true));
+    ScriptEventDispatcher.callEvent(nextSpeaker, new ScriptEvent(CREATE, true));
 
     currentSpeaker = nextSpeaker;
     currentSpeaker.zIndex = 200;
@@ -258,7 +258,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
       return;
     }
 
-    ScriptEventDispatcher.callEvent(nextDialogueBox, new ScriptEvent(ScriptEvent.CREATE, true));
+    ScriptEventDispatcher.callEvent(nextDialogueBox, new ScriptEvent(CREATE, true));
 
     currentDialogueBox = nextDialogueBox;
     currentDialogueBox.zIndex = 300;
@@ -293,7 +293,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   public function startConversation():Void
   {
-    dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_START, this, true));
+    dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, true));
   }
 
   /**
@@ -308,13 +308,13 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
     switch (state)
     {
       case ConversationState.Start:
-        dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_START, this, true));
+        dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, true));
       case ConversationState.Opening:
-        dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_COMPLETE_LINE, this, true));
+        dispatchEvent(new DialogueScriptEvent(DIALOGUE_COMPLETE_LINE, this, true));
       case ConversationState.Speaking:
-        dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_COMPLETE_LINE, this, true));
+        dispatchEvent(new DialogueScriptEvent(DIALOGUE_COMPLETE_LINE, this, true));
       case ConversationState.Idle:
-        dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_LINE, this, true));
+        dispatchEvent(new DialogueScriptEvent(DIALOGUE_LINE, this, true));
       case ConversationState.Ending:
         // Skip the outro.
         endOutro();
@@ -371,7 +371,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
    */
   public function skipConversation():Void
   {
-    dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_SKIP, this, true));
+    dispatchEvent(new DialogueScriptEvent(DIALOGUE_SKIP, this, true));
   }
 
   static var outroTween:FlxTween;
@@ -405,7 +405,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
   public function endOutro():Void
   {
     outroTween = null;
-    ScriptEventDispatcher.callEvent(this, new ScriptEvent(ScriptEvent.DESTROY, false));
+    ScriptEventDispatcher.callEvent(this, new ScriptEvent(DESTROY, false));
   }
 
   /**
@@ -445,7 +445,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
       if (currentDialogueEntry >= currentDialogueEntryCount)
       {
-        dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_END, this, false));
+        dispatchEvent(new DialogueScriptEvent(DIALOGUE_END, this, false));
       }
       else
       {
@@ -485,7 +485,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
     propagateEvent(event);
     if (event.eventCanceled) return;
 
-    dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_END, this, false));
+    dispatchEvent(new DialogueScriptEvent(DIALOGUE_END, this, false));
   }
 
   public function onDialogueEnd(event:DialogueScriptEvent):Void
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
index 4d7f74a58..70ac011a2 100644
--- a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
+++ b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
@@ -30,7 +30,7 @@ class ConversationDebugState extends MusicBeatState
     conversation.completeCallback = onConversationComplete;
     add(conversation);
 
-    ScriptEventDispatcher.callEvent(conversation, new ScriptEvent(ScriptEvent.CREATE, false));
+    ScriptEventDispatcher.callEvent(conversation, new ScriptEvent(CREATE, false));
   }
 
   function onConversationComplete():Void
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 60b8b9864..9562ef2ca 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -47,8 +47,8 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
    */
   public final _data:Null<SongMetadata>;
 
-  final _metadata:Array<SongMetadata>;
-
+  // key = variation id, value = metadata
+  final _metadata:Map<String, SongMetadata>;
   final variations:Array<String>;
   final difficulties:Map<String, SongDifficulty>;
 
@@ -62,7 +62,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
   function get_songName():String
   {
     if (_data != null) return _data?.songName ?? DEFAULT_SONGNAME;
-    if (_metadata.length > 0) return _metadata[0]?.songName ?? DEFAULT_SONGNAME;
+    if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.songName ?? DEFAULT_SONGNAME;
     return DEFAULT_SONGNAME;
   }
 
@@ -71,7 +71,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
   function get_songArtist():String
   {
     if (_data != null) return _data?.artist ?? DEFAULT_ARTIST;
-    if (_metadata.length > 0) return _metadata[0]?.artist ?? DEFAULT_ARTIST;
+    if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.artist ?? DEFAULT_ARTIST;
     return DEFAULT_ARTIST;
   }
 
@@ -88,7 +88,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
     _data = _fetchData(id);
 
-    _metadata = _data == null ? [] : [_data];
+    _metadata = _data == null ? [] : [Constants.DEFAULT_VARIATION => _data];
 
     variations.clear();
     variations.push(Constants.DEFAULT_VARIATION);
@@ -100,9 +100,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     }
 
     for (meta in fetchVariationMetadata(id))
-      _metadata.push(meta);
+      _metadata.set(meta.variation, meta);
 
-    if (_metadata.length == 0)
+    if (_metadata.size() == 0)
     {
       trace('[WARN] Could not find song data for songId: $id');
       return;
@@ -119,7 +119,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
     result._metadata.clear();
     for (meta in metadata)
-      result._metadata.push(meta);
+      result._metadata.set(meta.variation, meta);
 
     result.variations.clear();
     for (vari in variations)
@@ -138,7 +138,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
   public function getRawMetadata():Array<SongMetadata>
   {
-    return _metadata;
+    return _metadata.values();
   }
 
   /**
@@ -147,10 +147,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
    */
   function populateDifficulties():Void
   {
-    if (_metadata == null || _metadata.length == 0) return;
+    if (_metadata == null || _metadata.size() == 0) return;
 
     // Variations may have different artist, time format, generatedBy, etc.
-    for (metadata in _metadata)
+    for (metadata in _metadata.values())
     {
       if (metadata == null || metadata.playData == null) continue;
 
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index d9875e456..d7ba38e2a 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -7,6 +7,7 @@ import flixel.system.FlxAssets.FlxShader;
 import flixel.util.FlxSort;
 import funkin.modding.IScriptedClass;
 import funkin.modding.events.ScriptEvent;
+import funkin.modding.events.ScriptEventType;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
 import funkin.play.stage.StageData.StageDataCharacter;
@@ -402,7 +403,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     // Add the character to the scene.
     this.add(character);
 
-    ScriptEventDispatcher.callEvent(character, new ScriptEvent(ScriptEvent.ADDED, false));
+    ScriptEventDispatcher.callEvent(character, new ScriptEvent(ADDED, false));
 
     #if debug
     debugIconGroup.add(debugIcon);
diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
deleted file mode 100644
index 1014e67c2..000000000
--- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx
+++ /dev/null
@@ -1,879 +0,0 @@
-package funkin.ui.debug.charting;
-
-import haxe.ui.notifications.NotificationType;
-import haxe.ui.notifications.NotificationManager;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongDataUtils;
-
-using Lambda;
-
-/**
- * Actions in the chart editor are backed by the Command pattern
- * (see Bob Nystrom's book "Game Programming Patterns" for more info)
- *
- * To make a function compatible with the undo/redo history, create a new class
- * that implements ChartEditorCommand, then call `ChartEditorState.performCommand(new Command())`
- */
-interface ChartEditorCommand
-{
-  /**
-   * Calling this function should perform the action that this command represents.
-   * @param state The ChartEditorState to perform the action on.
-   */
-  public function execute(state:ChartEditorState):Void;
-
-  /**
-   * Calling this function should perform the inverse of the action that this command represents,
-   * effectively undoing the action.
-   * @param state The ChartEditorState to undo the action on.
-   */
-  public function undo(state:ChartEditorState):Void;
-
-  /**
-   * Get a short description of the action (for the UI).
-   * For example, return `Add Left Note` to display `Undo Add Left Note` in the menu.
-   */
-  public function toString():String;
-}
-
-@:nullSafety
-class AddNotesCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData>;
-  var appendToSelection:Bool;
-
-  public function new(notes:Array<SongNoteData>, appendToSelection:Bool = false)
-  {
-    this.notes = notes;
-    this.appendToSelection = appendToSelection;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    for (note in notes)
-    {
-      state.currentSongChartNoteData.push(note);
-    }
-
-    if (appendToSelection)
-    {
-      state.currentNoteSelection = state.currentNoteSelection.concat(notes);
-    }
-    else
-    {
-      state.currentNoteSelection = notes;
-      state.currentEventSelection = [];
-    }
-
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/noteLay'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-    state.currentNoteSelection = [];
-    state.currentEventSelection = [];
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/undo'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    if (notes.length == 1)
-    {
-      var dir:String = notes[0].getDirectionName();
-      return 'Add $dir Note';
-    }
-
-    return 'Add ${notes.length} Notes';
-  }
-}
-
-@:nullSafety
-class RemoveNotesCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData>;
-
-  public function new(notes:Array<SongNoteData>)
-  {
-    this.notes = notes;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-    state.currentNoteSelection = [];
-    state.currentEventSelection = [];
-
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/noteErase'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    for (note in notes)
-    {
-      state.currentSongChartNoteData.push(note);
-    }
-    state.currentNoteSelection = notes;
-    state.currentEventSelection = [];
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/undo'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    if (notes.length == 1 && notes[0] != null)
-    {
-      var dir:String = notes[0].getDirectionName();
-      return 'Remove $dir Note';
-    }
-
-    return 'Remove ${notes.length} Notes';
-  }
-}
-
-/**
- * Appends one or more items to the selection.
- */
-@:nullSafety
-class SelectItemsCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData>;
-  var events:Array<SongEventData>;
-
-  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
-  {
-    this.notes = notes;
-    this.events = events;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    for (note in this.notes)
-    {
-      state.currentNoteSelection.push(note);
-    }
-
-    for (event in this.events)
-    {
-      state.currentEventSelection.push(event);
-    }
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
-    state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function toString():String
-  {
-    var len:Int = notes.length + events.length;
-
-    if (notes.length == 0)
-    {
-      if (events.length == 1)
-      {
-        return 'Select Event';
-      }
-      else
-      {
-        return 'Select ${events.length} Events';
-      }
-    }
-    else if (events.length == 0)
-    {
-      if (notes.length == 1)
-      {
-        return 'Select Note';
-      }
-      else
-      {
-        return 'Select ${notes.length} Notes';
-      }
-    }
-
-    return 'Select ${len} Items';
-  }
-}
-
-@:nullSafety
-class AddEventsCommand implements ChartEditorCommand
-{
-  var events:Array<SongEventData>;
-  var appendToSelection:Bool;
-
-  public function new(events:Array<SongEventData>, appendToSelection:Bool = false)
-  {
-    this.events = events;
-    this.appendToSelection = appendToSelection;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    for (event in events)
-    {
-      state.currentSongChartEventData.push(event);
-    }
-
-    if (appendToSelection)
-    {
-      state.currentEventSelection = state.currentEventSelection.concat(events);
-    }
-    else
-    {
-      state.currentNoteSelection = [];
-      state.currentEventSelection = events;
-    }
-
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/noteLay'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
-
-    state.currentNoteSelection = [];
-    state.currentEventSelection = [];
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    var len:Int = events.length;
-    return 'Add $len Events';
-  }
-}
-
-@:nullSafety
-class RemoveEventsCommand implements ChartEditorCommand
-{
-  var events:Array<SongEventData>;
-
-  public function new(events:Array<SongEventData>)
-  {
-    this.events = events;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
-    state.currentEventSelection = [];
-
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/noteErase'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    for (event in events)
-    {
-      state.currentSongChartEventData.push(event);
-    }
-    state.currentEventSelection = events;
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/undo'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    if (events.length == 1 && events[0] != null)
-    {
-      return 'Remove Event';
-    }
-
-    return 'Remove ${events.length} Events';
-  }
-}
-
-@:nullSafety
-class RemoveItemsCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData>;
-  var events:Array<SongEventData>;
-
-  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
-  {
-    this.notes = notes;
-    this.events = events;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
-
-    state.currentNoteSelection = [];
-    state.currentEventSelection = [];
-
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/noteErase'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    for (note in notes)
-    {
-      state.currentSongChartNoteData.push(note);
-    }
-
-    for (event in events)
-    {
-      state.currentSongChartEventData.push(event);
-    }
-
-    state.currentNoteSelection = notes;
-    state.currentEventSelection = events;
-
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/undo'));
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    return 'Remove ${notes.length + events.length} Items';
-  }
-}
-
-@:nullSafety
-class SwitchDifficultyCommand implements ChartEditorCommand
-{
-  var prevDifficulty:String;
-  var newDifficulty:String;
-  var prevVariation:String;
-  var newVariation:String;
-
-  public function new(prevDifficulty:String, newDifficulty:String, prevVariation:String, newVariation:String)
-  {
-    this.prevDifficulty = prevDifficulty;
-    this.newDifficulty = newDifficulty;
-    this.prevVariation = prevVariation;
-    this.newVariation = newVariation;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.selectedVariation = newVariation != null ? newVariation : prevVariation;
-    state.selectedDifficulty = newDifficulty != null ? newDifficulty : prevDifficulty;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.selectedVariation = prevVariation != null ? prevVariation : newVariation;
-    state.selectedDifficulty = prevDifficulty != null ? prevDifficulty : newDifficulty;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function toString():String
-  {
-    return 'Switch Difficulty';
-  }
-}
-
-@:nullSafety
-class DeselectItemsCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData>;
-  var events:Array<SongEventData>;
-
-  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
-  {
-    this.notes = notes;
-    this.events = events;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
-    state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    for (note in this.notes)
-    {
-      state.currentNoteSelection.push(note);
-    }
-
-    for (event in this.events)
-    {
-      state.currentEventSelection.push(event);
-    }
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function toString():String
-  {
-    var noteCount = notes.length + events.length;
-
-    if (noteCount == 1)
-    {
-      var dir:String = notes[0].getDirectionName();
-      return 'Deselect $dir Items';
-    }
-
-    return 'Deselect ${noteCount} Items';
-  }
-}
-
-/**
- * Sets the selection rather than appends it.
- * Deselects any notes that are not in the new selection.
- */
-@:nullSafety
-class SetItemSelectionCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData>;
-  var events:Array<SongEventData>;
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
-
-  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, previousNoteSelection:Array<SongNoteData>,
-      previousEventSelection:Array<SongEventData>)
-  {
-    this.notes = notes;
-    this.events = events;
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = notes;
-    state.currentEventSelection = events;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = previousNoteSelection;
-    state.currentEventSelection = previousEventSelection;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function toString():String
-  {
-    return 'Select ${notes.length} Items';
-  }
-}
-
-@:nullSafety
-class SelectAllItemsCommand implements ChartEditorCommand
-{
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
-
-  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
-  {
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = state.currentSongChartNoteData;
-    state.currentEventSelection = state.currentSongChartEventData;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = previousNoteSelection;
-    state.currentEventSelection = previousEventSelection;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function toString():String
-  {
-    return 'Select All Items';
-  }
-}
-
-@:nullSafety
-class InvertSelectedItemsCommand implements ChartEditorCommand
-{
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
-
-  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
-  {
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection);
-    state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection);
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = previousNoteSelection;
-    state.currentEventSelection = previousEventSelection;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function toString():String
-  {
-    return 'Invert Selected Items';
-  }
-}
-
-@:nullSafety
-class DeselectAllItemsCommand implements ChartEditorCommand
-{
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
-
-  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
-  {
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = [];
-    state.currentEventSelection = [];
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentNoteSelection = previousNoteSelection;
-    state.currentEventSelection = previousEventSelection;
-
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-  }
-
-  public function toString():String
-  {
-    return 'Deselect All Items';
-  }
-}
-
-@:nullSafety
-class CutItemsCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData>;
-  var events:Array<SongEventData>;
-
-  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
-  {
-    this.notes = notes;
-    this.events = events;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    // Copy the notes.
-    SongDataUtils.writeItemsToClipboard(
-      {
-        notes: SongDataUtils.buildNoteClipboard(notes),
-        events: SongDataUtils.buildEventClipboard(events)
-      });
-
-    // Delete the notes.
-    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
-    state.currentNoteSelection = [];
-    state.currentEventSelection = [];
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
-    state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
-
-    state.currentNoteSelection = notes;
-    state.currentEventSelection = events;
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    var len:Int = notes.length + events.length;
-
-    if (notes.length == 0) return 'Cut $len Events to Clipboard';
-    else if (events.length == 0) return 'Cut $len Notes to Clipboard';
-    else
-      return 'Cut $len Items to Clipboard';
-  }
-}
-
-@:nullSafety
-class FlipNotesCommand implements ChartEditorCommand
-{
-  var notes:Array<SongNoteData> = [];
-  var flippedNotes:Array<SongNoteData> = [];
-
-  public function new(notes:Array<SongNoteData>)
-  {
-    this.notes = notes;
-    this.flippedNotes = SongDataUtils.flipNotes(notes);
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    // Delete the notes.
-    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
-
-    // Add the flipped notes.
-    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes);
-
-    state.currentNoteSelection = flippedNotes;
-    state.currentEventSelection = [];
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, flippedNotes);
-    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
-
-    state.currentNoteSelection = notes;
-    state.currentEventSelection = [];
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    var len:Int = notes.length;
-    return 'Flip $len Notes';
-  }
-}
-
-@:nullSafety
-class PasteItemsCommand implements ChartEditorCommand
-{
-  var targetTimestamp:Float;
-  // Notes we added with this command, for undo.
-  var addedNotes:Array<SongNoteData> = [];
-  var addedEvents:Array<SongEventData> = [];
-
-  public function new(targetTimestamp:Float)
-  {
-    this.targetTimestamp = targetTimestamp;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
-
-    if (currentClipboard.valid != true)
-    {
-      #if !mac
-      NotificationManager.instance.addNotification(
-        {
-          title: 'Failed to Paste',
-          body: 'Could not parse clipboard contents.',
-          type: NotificationType.Error,
-          expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
-        });
-      #end
-      return;
-    }
-
-    trace(currentClipboard.notes);
-
-    addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp));
-    addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp));
-
-    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes);
-    state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents);
-    state.currentNoteSelection = addedNotes.copy();
-    state.currentEventSelection = addedEvents.copy();
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-
-    #if !mac
-    NotificationManager.instance.addNotification(
-      {
-        title: 'Paste Successful',
-        body: 'Successfully pasted clipboard contents.',
-        type: NotificationType.Success,
-        expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
-      });
-    #end
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/undo'));
-
-    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes);
-    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents);
-    state.currentNoteSelection = [];
-    state.currentEventSelection = [];
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
-
-    var len:Int = currentClipboard.notes.length + currentClipboard.events.length;
-
-    if (currentClipboard.notes.length == 0) return 'Paste $len Events';
-    else if (currentClipboard.events.length == 0) return 'Paste $len Notes';
-    else
-      return 'Paste $len Items';
-  }
-}
-
-@:nullSafety
-class ExtendNoteLengthCommand implements ChartEditorCommand
-{
-  var note:SongNoteData;
-  var oldLength:Float;
-  var newLength:Float;
-
-  public function new(note:SongNoteData, newLength:Float)
-  {
-    this.note = note;
-    this.oldLength = note.length;
-    this.newLength = newLength;
-  }
-
-  public function execute(state:ChartEditorState):Void
-  {
-    note.length = newLength;
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function undo(state:ChartEditorState):Void
-  {
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/undo'));
-
-    note.length = oldLength;
-
-    state.saveDataDirty = true;
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
-
-    state.sortChartData();
-  }
-
-  public function toString():String
-  {
-    return 'Extend Note Length';
-  }
-}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 05173726f..8c18271d9 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,22 +1,14 @@
 package funkin.ui.debug.charting;
 
-import funkin.play.stage.StageData;
-import funkin.play.character.CharacterData.CharacterDataParser;
-import funkin.play.character.CharacterData;
-import flixel.system.FlxAssets.FlxSoundAsset;
-import flixel.math.FlxMath;
-import haxe.ui.components.TextField;
-import haxe.ui.components.DropDown;
-import haxe.ui.components.NumberStepper;
-import haxe.ui.containers.Frame;
 import flixel.addons.display.FlxSliceSprite;
 import flixel.addons.display.FlxTiledSprite;
+import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
 import flixel.group.FlxSpriteGroup;
-import flixel.addons.transition.FlxTransitionableState;
 import flixel.input.keyboard.FlxKey;
+import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
 import flixel.sound.FlxSound;
@@ -29,41 +21,59 @@ import flixel.util.FlxTimer;
 import funkin.audio.visualize.PolygonSpectogram;
 import funkin.audio.VoicesGroup;
 import funkin.data.notestyle.NoteStyleRegistry;
-import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.data.song.SongData.SongChartData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
 import funkin.input.Cursor;
 import funkin.input.TurboKeyHandler;
 import funkin.modding.events.ScriptEvent;
 import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.play.character.CharacterData;
 import funkin.play.HealthIcon;
 import funkin.play.notes.NoteSprite;
-import funkin.play.notes.Strumline;
 import funkin.play.PlayState;
 import funkin.play.song.Song;
-import funkin.data.song.SongData.SongChartData;
-import funkin.data.song.SongRegistry;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongDataUtils;
-import funkin.ui.debug.charting.ChartEditorCommand;
-import funkin.ui.debug.charting.ChartEditorCommand;
-import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme;
-import funkin.ui.debug.charting.ChartEditorToolboxHandler.ChartEditorToolMode;
+import funkin.play.stage.StageData;
+import funkin.ui.debug.charting.commands.AddEventsCommand;
+import funkin.ui.debug.charting.commands.AddNotesCommand;
+import funkin.ui.debug.charting.commands.ChartEditorCommand;
+import funkin.ui.debug.charting.commands.CutItemsCommand;
+import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
+import funkin.ui.debug.charting.commands.DeselectItemsCommand;
+import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand;
+import funkin.ui.debug.charting.commands.FlipNotesCommand;
+import funkin.ui.debug.charting.commands.InvertSelectedItemsCommand;
+import funkin.ui.debug.charting.commands.MoveEventsCommand;
+import funkin.ui.debug.charting.commands.MoveItemsCommand;
+import funkin.ui.debug.charting.commands.MoveNotesCommand;
+import funkin.ui.debug.charting.commands.PasteItemsCommand;
+import funkin.ui.debug.charting.commands.RemoveEventsCommand;
+import funkin.ui.debug.charting.commands.RemoveItemsCommand;
+import funkin.ui.debug.charting.commands.RemoveNotesCommand;
+import funkin.ui.debug.charting.commands.SelectAllItemsCommand;
+import funkin.ui.debug.charting.commands.SelectItemsCommand;
+import funkin.ui.debug.charting.commands.SetItemSelectionCommand;
+import funkin.ui.debug.charting.components.ChartEditorEventSprite;
+import funkin.ui.debug.charting.components.ChartEditorHoldNoteSprite;
+import funkin.ui.debug.charting.components.ChartEditorNotePreview;
+import funkin.ui.debug.charting.components.ChartEditorNoteSprite;
+import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.ui.haxeui.HaxeUIState;
 import funkin.util.Constants;
-import funkin.util.DateUtil;
-import funkin.util.FileUtil;
-import funkin.util.SerializerUtil;
 import funkin.util.SortUtil;
 import funkin.util.WindowUtil;
 import haxe.DynamicAccess;
 import haxe.io.Bytes;
-import haxe.io.Path;
+import haxe.ui.components.DropDown;
 import haxe.ui.components.Label;
+import haxe.ui.components.NumberStepper;
 import haxe.ui.components.Slider;
+import haxe.ui.components.TextField;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
+import haxe.ui.containers.Frame;
 import haxe.ui.containers.menus.MenuItem;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
@@ -73,9 +83,7 @@ import haxe.ui.events.DragEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.notifications.NotificationManager;
 import haxe.ui.notifications.NotificationType;
-import openfl.Assets;
 import openfl.display.BitmapData;
-import openfl.geom.Rectangle;
 
 using Lambda;
 
@@ -88,14 +96,6 @@ using Lambda;
  * @author MasterEric
  */
 @:nullSafety
-// Give other classes access to private instance fields
-@:allow(funkin.ui.debug.charting.ChartEditorCommand)
-@:allow(funkin.ui.debug.charting.ChartEditorDropdowns)
-@:allow(funkin.ui.debug.charting.ChartEditorDialogHandler)
-@:allow(funkin.ui.debug.charting.ChartEditorThemeHandler)
-@:allow(funkin.ui.debug.charting.ChartEditorAudioHandler)
-@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler)
-@:allow(funkin.ui.debug.charting.ChartEditorToolboxHandler)
 class ChartEditorState extends HaxeUIState
 {
   /**
@@ -103,129 +103,223 @@ class ChartEditorState extends HaxeUIState
    */
   // ==============================
   // XML Layouts
-  static final CHART_EDITOR_LAYOUT:String = Paths.ui('chart-editor/main-view');
+  public static final CHART_EDITOR_LAYOUT:String = Paths.ui('chart-editor/main-view');
 
-  static final CHART_EDITOR_NOTIFBAR_LAYOUT:String = Paths.ui('chart-editor/components/notifbar');
-  static final CHART_EDITOR_PLAYBARHEAD_LAYOUT:String = Paths.ui('chart-editor/components/playbar-head');
+  public static final CHART_EDITOR_NOTIFBAR_LAYOUT:String = Paths.ui('chart-editor/components/notifbar');
+  public static final CHART_EDITOR_PLAYBARHEAD_LAYOUT:String = Paths.ui('chart-editor/components/playbar-head');
 
-  static final CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:String = Paths.ui('chart-editor/toolbox/tools');
-  static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
-  static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
-  static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
-  static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
-  static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
-  static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
+  public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
+  public static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
+  public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
+  public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
+  public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
+  public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
 
   // Validation
-  static final SUPPORTED_MUSIC_FORMATS:Array<String> = ['ogg'];
+  public static final SUPPORTED_MUSIC_FORMATS:Array<String> = ['ogg'];
+
+  // Layout
 
   /**
    * The base grid size for the chart editor.
    */
   public static final GRID_SIZE:Int = 40;
 
+  /**
+   * The width of the scroll area.
+   */
   public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12;
 
+  /**
+   * The height of the playhead, in pixels.
+   */
   public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8);
 
+  /**
+   * The width of the border between grid squares, where the crosshair changes from "Place Notes" to "Select Notes".
+   */
   public static final GRID_SELECTION_BORDER_WIDTH:Int = 6;
 
+  /**
+   * The height of the menu bar in the layout.
+   */
+  public static final MENU_BAR_HEIGHT:Int = 32;
+
+  /**
+   * The height of the playbar in the layout.
+   */
+  public static final PLAYBAR_HEIGHT:Int = 48;
+
+  /**
+   * The amount of padding between the menu bar and the chart grid when fully scrolled up.
+   */
+  public static final GRID_TOP_PAD:Int = 8;
+
+  // Colors
+  // Background color tint.
+  public static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF;
+  public static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030;
+  public static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F;
+  public static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000;
+  public static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231;
+
+  // Timings
+
+  /**
+   * Duration, in seconds, for the scroll easing animation.
+   */
+  public static final SCROLL_EASE_DURATION:Float = 0.2;
+
+  // Other
+
   /**
    * Number of notes in each player's strumline.
    */
   public static final STRUMLINE_SIZE:Int = 4;
 
-  /**
-   * The height of the menu bar in the layout.
-   */
-  static final MENU_BAR_HEIGHT:Int = 32;
-
-  /**
-   * The height of the playbar in the layout.
-   */
-  static final PLAYBAR_HEIGHT:Int = 48;
-
-  /**
-   * Duration to wait before autosaving the chart.
-   */
-  static final AUTOSAVE_TIMER_DELAY:Float = 60.0 * 5.0;
-
-  /**
-   * The amount of padding between the menu bar and the chart grid when fully scrolled up.
-   */
-  static final GRID_TOP_PAD:Int = 8;
-
-  /**
-   * Duration, in milliseconds, until toast notifications are automatically hidden.
-   */
-  static final NOTIFICATION_DISMISS_TIME:Int = 5000;
-
-  /**
-   * Duration, in seconds, for the scroll easing animation.
-   */
-  static final SCROLL_EASE_DURATION:Float = 0.2;
-
-  // UI Element Colors
-  // Background color tint.
-  static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF;
-  static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030;
-  static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F;
-  static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000;
-  static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231;
-
   /**
    * How many pixels far the user needs to move the mouse before the cursor is considered to be dragged rather than clicked.
    */
-  static final DRAG_THRESHOLD:Float = 16.0;
+  public static final DRAG_THRESHOLD:Float = 16.0;
 
   /**
-   * Types of notes you can snap to.
+   * Precisions of notes you can snap to.
    */
-  static final SNAP_QUANTS:Array<Int> = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192];
+  public static final SNAP_QUANTS:Array<Int> = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192];
 
-  static final BASE_QUANT:Int = 16;
+  /**
+   * The default note snapping value.
+   */
+  public static final BASE_QUANT:Int = 16;
+
+  /**
+   * The index of thet default note snapping value in the `SNAP_QUANTS` array.
+   */
+  public static final BASE_QUANT_INDEX:Int = 3;
 
   /**
    * INSTANCE DATA
    */
   // ==============================
+  // Song Length
 
   /**
-   * The internal index of what note snapping value is in use.
-   * Increment to make placement more preceise and decrement to make placement less precise.
+   * The length of the current instrumental, in milliseconds.
    */
-  var noteSnapQuantIndex:Int = 3; // default is 16
+  @:isVar var songLengthInMs(get, set):Float = 0;
 
-  /**
-   * The current note snapping value.
-   * For example, `32` when snapping to 32nd notes.
-   */
-  public var noteSnapQuant(get, never):Int;
-
-  function get_noteSnapQuant():Int
+  function get_songLengthInMs():Float
   {
-    return SNAP_QUANTS[noteSnapQuantIndex];
+    if (songLengthInMs <= 0) return 1000;
+    return songLengthInMs;
+  }
+
+  function set_songLengthInMs(value:Float):Float
+  {
+    this.songLengthInMs = value;
+
+    // Make sure playhead doesn't go outside the song.
+    if (playheadPositionInMs > songLengthInMs) playheadPositionInMs = songLengthInMs;
+
+    return this.songLengthInMs;
   }
 
   /**
-   * The ratio of the current note snapping value to the default.
-   * For example, `32` becomes `0.5` when snapping to 16th notes.
+   * The length of the current instrumental, converted to steps.
+   * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does.
    */
-  public var noteSnapRatio(get, never):Float;
+  var songLengthInSteps(get, set):Float;
 
-  function get_noteSnapRatio():Float
+  function get_songLengthInSteps():Float
   {
-    return BASE_QUANT / noteSnapQuant;
+    return Conductor.getTimeInSteps(songLengthInMs);
+  }
+
+  function set_songLengthInSteps(value:Float):Float
+  {
+    // Getting a reasonable result from setting songLengthInSteps requires that Conductor.mapBPMChanges be called first.
+    songLengthInMs = Conductor.getStepTimeInMs(value);
+    return value;
   }
 
   /**
-   * scrollPosition is the current position in the song, in pixels.
+   * The length of the current instrumental, in PIXELS.
+   * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does.
+   */
+  var songLengthInPixels(get, set):Int;
+
+  function get_songLengthInPixels():Int
+  {
+    return Std.int(songLengthInSteps * GRID_SIZE);
+  }
+
+  function set_songLengthInPixels(value:Int):Int
+  {
+    songLengthInSteps = value / GRID_SIZE;
+    return value;
+  }
+
+  // Scroll Position
+
+  /**
+   * The relative scroll position in the song, in pixels.
    * One pixel is 1/40 of 1 step, and 1/160 of 1 beat.
    */
   var scrollPositionInPixels(default, set):Float = -1.0;
 
+  function set_scrollPositionInPixels(value:Float):Float
+  {
+    if (value < 0)
+    {
+      // If we're scrolling up, and we hit the top,
+      // but the playhead is in the middle, move the playhead up.
+      if (playheadPositionInPixels > 0)
+      {
+        var amount:Float = scrollPositionInPixels - value;
+        playheadPositionInPixels -= amount;
+      }
+
+      value = 0;
+    }
+
+    if (value > songLengthInPixels) value = songLengthInPixels;
+
+    if (value == scrollPositionInPixels) return value;
+
+    // Difference in pixels.
+    var diff:Float = value - scrollPositionInPixels;
+
+    this.scrollPositionInPixels = value;
+
+    // Move the grid sprite to the correct position.
+    if (gridTiledSprite != null && gridPlayheadScrollArea != null)
+    {
+      if (isViewDownscroll)
+      {
+        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+        gridPlayheadScrollArea.y = gridTiledSprite.y;
+      }
+      else
+      {
+        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+        gridPlayheadScrollArea.y = gridTiledSprite.y;
+      }
+    }
+
+    // Move the rendered notes to the correct position.
+    renderedNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
+    renderedHoldNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
+    renderedEvents.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
+    renderedSelectionSquares.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
+    // Offset the selection box start position, if we are dragging.
+    if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff;
+    // Update the note preview viewport box.
+    setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+    return this.scrollPositionInPixels;
+  }
+
   /**
-   * scrollPosition, converted to steps.
+   * The relative scroll position in the song, converted to steps.
    * NOT dependant on BPM, because the size of a grid square does not change with BPM.
    */
   var scrollPositionInSteps(get, set):Float;
@@ -242,7 +336,7 @@ class ChartEditorState extends HaxeUIState
   }
 
   /**
-   * scrollPosition, converted to milliseconds.
+   * The relative scroll position in the song, converted to milliseconds.
    * DEPENDANT on BPM, because the duration of a grid square changes with BPM.
    */
   var scrollPositionInMs(get, set):Float;
@@ -258,11 +352,13 @@ class ChartEditorState extends HaxeUIState
     return value;
   }
 
+  // Playhead (on the grid)
+
   /**
-   * The position of the playhead, in pixels, relative to the scrollPosition.
-   * 0 means playhead is at the top of the grid.
-   * 40 means the playhead is 1 grid length below the base position.
-   * -40 means the playhead is 1 grid length above the base position.
+   * The position of the playhead, in pixels, relative to the `scrollPositionInPixels`.
+   * `0` means playhead is at the top of the grid.
+   * `40` means the playhead is 1 grid length below the base position.
+   * `-40` means the playhead is 1 grid length above the base position.
    */
   var playheadPositionInPixels(default, set):Float = 0.0;
 
@@ -314,77 +410,7 @@ class ChartEditorState extends HaxeUIState
     return value;
   }
 
-  /**
-   * songLength, in milliseconds.
-   */
-  @:isVar var songLengthInMs(get, set):Float = 0;
-
-  function get_songLengthInMs():Float
-  {
-    if (songLengthInMs <= 0) return 1000;
-    return songLengthInMs;
-  }
-
-  function set_songLengthInMs(value:Float):Float
-  {
-    this.songLengthInMs = value;
-
-    // Make sure playhead doesn't go outside the song.
-    if (playheadPositionInMs > songLengthInMs) playheadPositionInMs = songLengthInMs;
-
-    return this.songLengthInMs;
-  }
-
-  /**
-   * songLength, converted to steps.
-   * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does.
-   */
-  var songLengthInSteps(get, set):Float;
-
-  function get_songLengthInSteps():Float
-  {
-    return Conductor.getTimeInSteps(songLengthInMs);
-  }
-
-  function set_songLengthInSteps(value:Float):Float
-  {
-    // Getting a reasonable result from setting songLengthInSteps requires that Conductor.mapBPMChanges be called first.
-    songLengthInMs = Conductor.getStepTimeInMs(value);
-    return value;
-  }
-
-  /**
-   * This is the song's length in PIXELS, same format as scrollPosition.
-   * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does.
-   */
-  var songLengthInPixels(get, set):Int;
-
-  function get_songLengthInPixels():Int
-  {
-    return Std.int(songLengthInSteps * GRID_SIZE);
-  }
-
-  function set_songLengthInPixels(value:Int):Int
-  {
-    songLengthInSteps = value / GRID_SIZE;
-    return value;
-  }
-
-  /**
-   * The current theme used by the editor.
-   * Dictates the appearance of many UI elements.
-   * Currently hardcoded to just Light and Dark.
-   */
-  var currentTheme(default, set):ChartEditorTheme = ChartEditorTheme.Light;
-
-  function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme
-  {
-    if (value == null || value == currentTheme) return currentTheme;
-
-    currentTheme = value;
-    ChartEditorThemeHandler.updateTheme(this);
-    return value;
-  }
+  // Playbar (at the bottom)
 
   /**
    * Whether a skip button has been pressed on the playbar, and which one.
@@ -404,47 +430,62 @@ class ChartEditorState extends HaxeUIState
    */
   var playbarHeadDraggingWasPlaying:Bool = false;
 
+  // Tools Status
+
   /**
    * The note kind to use for notes being placed in the chart. Defaults to `''`.
    */
   var selectedNoteKind:String = '';
 
   /**
-   * The note kind to use for notes being placed in the chart. Defaults to `''`.
+   * The event type to use for events being placed in the chart. Defaults to `''`.
    */
   var selectedEventKind:String = 'FocusCamera';
 
   /**
-   * The note data as a struct.
+   * The event data to use for events being placed in the chart.
    */
   var selectedEventData:DynamicAccess<Dynamic> = {};
 
   /**
-   * Whether to play a metronome sound while the playhead is moving.
+   * The internal index of what note snapping value is in use.
+   * Increment to make placement more preceise and decrement to make placement less precise.
    */
-  var isMetronomeEnabled:Bool = true;
+  var noteSnapQuantIndex:Int = BASE_QUANT_INDEX;
 
   /**
-   * Use the tool window to affect how the user interacts with the program.
+   * The current note snapping value.
+   * For example, `32` when snapping to 32nd notes.
    */
-  var currentToolMode:ChartEditorToolMode = ChartEditorToolMode.Select;
+  var noteSnapQuant(get, never):Int;
+
+  function get_noteSnapQuant():Int
+  {
+    return SNAP_QUANTS[noteSnapQuantIndex];
+  }
 
   /**
-   * The character sprite in the Player Preview window.
-   * `null` until accessed.
+   * The ratio of the current note snapping value to the default.
+   * For example, `32` becomes `0.5` when snapping to 16th notes.
    */
-  var currentPlayerCharacterPlayer:Null<CharacterPlayer> = null;
+  var noteSnapRatio(get, never):Float;
 
-  /**
-   * The character sprite in the Opponent Preview window.
-   * `null` until accessed.
-   */
-  var currentOpponentCharacterPlayer:Null<CharacterPlayer> = null;
+  function get_noteSnapRatio():Float
+  {
+    return BASE_QUANT / noteSnapQuant;
+  }
 
   /**
    * The currently selected live input style.
    */
-  var currentLiveInputStyle:LiveInputStyle = LiveInputStyle.None;
+  var currentLiveInputStyle:ChartEditorLiveInputStyle = None;
+
+  /**
+   * If true, playtesting a chart will skip to the current playhead position.
+   */
+  var playtestStartTime:Bool = false;
+
+  // Visuals
 
   /**
    * Whether the current view is in downscroll mode.
@@ -467,33 +508,38 @@ class ChartEditorState extends HaxeUIState
   }
 
   /**
-   * If true, playtesting a chart will skip to the current playhead position.
+   * The current theme used by the editor.
+   * Dictates the appearance of many UI elements.
+   * Currently hardcoded to just Light and Dark.
    */
-  var playtestStartTime:Bool = false;
+  var currentTheme(default, set):ChartEditorTheme = ChartEditorTheme.Light;
 
-  /**
-   * Whether hitsounds are enabled for at least one character.
-   */
-  var hitsoundsEnabled(get, never):Bool;
-
-  function get_hitsoundsEnabled():Bool
+  function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme
   {
-    return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent;
+    if (value == null || value == currentTheme) return currentTheme;
+
+    currentTheme = value;
+    this.updateTheme();
+    return value;
   }
 
   /**
-   * Whether hitsounds are enabled for the player.
+   * The character sprite in the Player Preview window.
+   * `null` until accessed.
    */
-  var hitsoundsEnabledPlayer:Bool = true;
+  var currentPlayerCharacterPlayer:Null<CharacterPlayer> = null;
 
   /**
-   * Whether hitsounds are enabled for the opponent.
+   * The character sprite in the Opponent Preview window.
+   * `null` until accessed.
    */
-  var hitsoundsEnabledOpponent:Bool = true;
+  var currentOpponentCharacterPlayer:Null<CharacterPlayer> = null;
+
+  // HaxeUI
 
   /**
    * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI.
-   * If so, ignore mouse events underneath.
+   * If so, ignore mouse events underneath as well as certain key events.
    */
   var isCursorOverHaxeUI(get, never):Bool;
 
@@ -513,107 +559,143 @@ class ChartEditorState extends HaxeUIState
   /**
    * Set by ChartEditorDialogHandler, used to prevent background interaction while the dialog is open.
    */
-  public var isHaxeUIDialogOpen:Bool = false;
+  var isHaxeUIDialogOpen:Bool = false;
 
   /**
-   * The variation ID for the difficulty which is currently being edited.
+   * The Dialog components representing the currently available tool windows.
+   * Dialogs are retained here even when collapsed or hidden.
    */
-  var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION;
+  var activeToolboxes:Map<String, CollapsibleDialog> = new Map<String, CollapsibleDialog>();
+
+  // Audio
 
   /**
-   * Setter called when we are switching variations.
-   * We will likely need to switch instrumentals as well.
+   * Whether to play a metronome sound while the playhead is moving.
    */
-  function set_selectedVariation(value:String):String
-  {
-    // Don't update if we're already on the variation.
-    if (selectedVariation == value) return selectedVariation;
-    selectedVariation = value;
-
-    // Make sure view is updated when the variation changes.
-    noteDisplayDirty = true;
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-
-    switchToCurrentInstrumental();
-
-    return selectedVariation;
-  }
+  var isMetronomeEnabled:Bool = true;
 
   /**
-   * The difficulty ID for the difficulty which is currently being edited.
+   * Whether hitsounds are enabled for the player.
    */
-  var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
-
-  function set_selectedDifficulty(value:String):String
-  {
-    selectedDifficulty = value;
-
-    // Make sure view is updated when the difficulty changes.
-    noteDisplayDirty = true;
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-
-    // Make sure the difficulty we selected is in the list of difficulties.
-    currentSongMetadata.playData.difficulties.pushUnique(selectedDifficulty);
-
-    return selectedDifficulty;
-  }
+  var hitsoundsEnabledPlayer:Bool = true;
 
   /**
-   * The instrumental ID which is currently selected.
+   * Whether hitsounds are enabled for the opponent.
    */
-  var currentInstrumentalId(get, set):String;
-
-  function get_currentInstrumentalId():String
-  {
-    var instId:Null<String> = currentSongMetadata.playData.characters.instrumental;
-    if (instId == null || instId == '') instId = (selectedVariation == Constants.DEFAULT_VARIATION) ? '' : selectedVariation;
-    return instId;
-  }
-
-  function set_currentInstrumentalId(value:String):String
-  {
-    return currentSongMetadata.playData.characters.instrumental = value;
-  }
+  var hitsoundsEnabledOpponent:Bool = true;
 
   /**
-   * The character ID for the character which is currently selected.
+   * Whether hitsounds are enabled for at least one character.
    */
-  var selectedCharacter(default, set):String = Constants.DEFAULT_CHARACTER;
+  var hitsoundsEnabled(get, never):Bool;
 
-  function set_selectedCharacter(value:String):String
+  function get_hitsoundsEnabled():Bool
   {
-    selectedCharacter = value;
-
-    // Make sure view is updated when the character changes.
-    noteDisplayDirty = true;
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-
-    return selectedCharacter;
+    return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent;
   }
 
+  // Auto-save
+
   /**
-   * Whether the user is currently in Pattern Mode.
-   * This overrides the chart editor's normal behavior.
+   * A timer used to auto-save the chart after a period of inactivity.
    */
-  var isInPatternMode(default, set):Bool = false;
+  var autoSaveTimer:Null<FlxTimer> = null;
 
-  function set_isInPatternMode(value:Bool):Bool
-  {
-    isInPatternMode = value;
+  // Scrolling
 
-    // Make sure view is updated when we change modes.
-    noteDisplayDirty = true;
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-    this.scrollPositionInPixels = 0;
+  /**
+   * Whether the user's last mouse click was on the playhead scroll area.
+   */
+  var gridPlayheadScrollAreaPressed:Bool = false;
 
-    return isInPatternMode;
-  }
+  /**
+   * Where the user's last mouse click was on the note preview scroll area.
+   * `null` if the user isn't clicking on the note preview.
+   */
+  var notePreviewScrollAreaStartPos:Null<FlxPoint> = null;
 
-  var currentPattern:String = '';
+  /**
+   * The current process that is lerping the scroll position.
+   * Used to cancel the previous lerp if the user scrolls again.
+   */
+  var currentScrollEase:Null<VarTween>;
+
+  // Note Placement
+
+  /**
+   * The SongNoteData which is currently being placed.
+   * `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;
+
+  // Note Movement
+
+  /**
+   * The note sprite we are currently moving, if any.
+   */
+  var dragTargetNote:Null<ChartEditorNoteSprite> = null;
+
+  /**
+   * The song event sprite we are currently moving, if any.
+   */
+  var dragTargetEvent:Null<ChartEditorEventSprite> = null;
+
+  /**
+   * The amount of vertical steps the note sprite has moved by since the user started dragging.
+   */
+  var dragTargetCurrentStep:Float = 0;
+
+  /**
+   * The amount of horizontal columns the note sprite has moved by since the user started dragging.
+   */
+  var dragTargetCurrentColumn:Int = 0;
+
+  // Hold Note Dragging
+
+  /**
+   * The current length of the hold note we are dragging, in steps.
+   * Play a sound when this value changes.
+   */
+  var dragLengthCurrent:Float = 0;
+
+  /**
+   * Flip-flop to alternate between two stretching sounds.
+   */
+  var stretchySounds:Bool = false;
+
+  // Selection
+
+  /**
+   * The notes which are currently in the user's selection.
+   */
+  var currentNoteSelection:Array<SongNoteData> = [];
+
+  /**
+   * The events which are currently in the user's selection.
+   */
+  var currentEventSelection:Array<SongEventData> = [];
+
+  /**
+   * The position where the user clicked to start a selection.
+   * `null` if the user isn't currently selecting anything.
+   * The selection box extends from this point to the current mouse position.
+   */
+  var selectionBoxStartPos:Null<FlxPoint> = null;
+
+  // History
+
+  /**
+   * The list of command previously performed. Used for undoing previous actions.
+   */
+  var undoHistory:Array<ChartEditorCommand> = [];
+
+  /**
+   * The list of commands that have been undone. Used for redoing previous actions.
+   */
+  var redoHistory:Array<ChartEditorCommand> = [];
+
+  // Dirty Flags
 
   /**
    * Whether the note display render group has been modified and needs to be updated.
@@ -652,7 +734,7 @@ class ChartEditorState extends HaxeUIState
     if (value)
     {
       // Start the auto-save timer.
-      autoSaveTimer = new FlxTimer().start(AUTOSAVE_TIMER_DELAY, (_) -> autoSave());
+      autoSaveTimer = new FlxTimer().start(Constants.AUTOSAVE_TIMER_DELAY_SEC, (_) -> autoSave());
     }
     else
     {
@@ -668,11 +750,6 @@ class ChartEditorState extends HaxeUIState
     return saveDataDirty = value;
   }
 
-  /**
-   * A timer used to auto-save the chart after a period of inactivity.
-   */
-  var autoSaveTimer:Null<FlxTimer> = null;
-
   /**
    * Whether the difficulty tree view in the toolbox has been modified and needs to be updated.
    * This happens when we add/remove difficulties.
@@ -697,17 +774,12 @@ class ChartEditorState extends HaxeUIState
    */
   var opponentPreviewDirty:Bool = true;
 
-  var isInPlaytestMode:Bool = false;
-
   /**
-   * The list of command previously performed. Used for undoing previous actions.
+   * Whether the undo/redo histories have changed since the last time the UI was updated.
    */
-  var undoHistory:Array<ChartEditorCommand> = [];
+  var commandHistoryDirty:Bool = true;
 
-  /**
-   * The list of commands that have been undone. Used for redoing previous actions.
-   */
-  var redoHistory:Array<ChartEditorCommand> = [];
+  // Input
 
   /**
    * Handler used to track how long the user has been holding the undo keybind.
@@ -749,52 +821,6 @@ class ChartEditorState extends HaxeUIState
    */
   var pageDownKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.PAGEDOWN);
 
-  /**
-   * Whether the undo/redo histories have changed since the last time the UI was updated.
-   */
-  var commandHistoryDirty:Bool = true;
-
-  /**
-   * The notes which are currently in the user's selection.
-   */
-  var currentNoteSelection:Array<SongNoteData> = [];
-
-  /**
-   * The events which are currently in the user's selection.
-   */
-  var currentEventSelection:Array<SongEventData> = [];
-
-  /**
-   * The position where the user clicked to start a selection.
-   * `null` if the user isn't currently selecting anything.
-   * The selection box extends from this point to the current mouse position.
-   */
-  var selectionBoxStartPos:Null<FlxPoint> = null;
-
-  /**
-   * Whether the user's last mouse click was on the playhead scroll area.
-   */
-  var gridPlayheadScrollAreaPressed:Bool = false;
-
-  /**
-   * Where the user's last mouse click was on the note preview scroll area.
-   * `null` if the user isn't clicking on the note preview.
-   */
-  var notePreviewScrollAreaStartPos:Null<FlxPoint> = null;
-
-  /**
-   * The SongNoteData which is currently being placed.
-   * `null` if the user isn't currently placing a note.
-   * As the user drags, we will update this note's sustain length.
-   */
-  var currentPlaceNoteData:Null<SongNoteData> = null;
-
-  /**
-   * The Dialog components representing the currently available tool windows.
-   * Dialogs are retained here even when collapsed or hidden.
-   */
-  var activeToolboxes:Map<String, CollapsibleDialog> = new Map<String, CollapsibleDialog>();
-
   /**
    * AUDIO AND SOUND DATA
    */
@@ -803,7 +829,7 @@ class ChartEditorState extends HaxeUIState
   /**
    * The chill audio track that plays when you open the Chart Editor.
    */
-  public var welcomeMusic:FlxSound = new FlxSound();
+  var welcomeMusic:FlxSound = new FlxSound();
 
   /**
    * The audio track for the instrumental.
@@ -1007,7 +1033,7 @@ class ChartEditorState extends HaxeUIState
     return currentSongChartData.events = value;
   }
 
-  public var currentSongNoteStyle(get, set):String;
+  var currentSongNoteStyle(get, set):String;
 
   function get_currentSongNoteStyle():String
   {
@@ -1083,10 +1109,67 @@ class ChartEditorState extends HaxeUIState
   }
 
   /**
-   * SIGNALS
+   * The variation ID for the difficulty which is currently being edited.
    */
-  // ==============================
-  // public var onDifficultyChange(default, never):FlxTypedSignal<ChartEditorState->Void> = new FlxTypedSignal<ChartEditorState->Void>();
+  var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION;
+
+  /**
+   * Setter called when we are switching variations.
+   * We will likely need to switch instrumentals as well.
+   */
+  function set_selectedVariation(value:String):String
+  {
+    // Don't update if we're already on the variation.
+    if (selectedVariation == value) return selectedVariation;
+    selectedVariation = value;
+
+    // Make sure view is updated when the variation changes.
+    noteDisplayDirty = true;
+    notePreviewDirty = true;
+    notePreviewViewportBoundsDirty = true;
+
+    switchToCurrentInstrumental();
+
+    return selectedVariation;
+  }
+
+  /**
+   * The difficulty ID for the difficulty which is currently being edited.
+   */
+  var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
+
+  function set_selectedDifficulty(value:String):String
+  {
+    selectedDifficulty = value;
+
+    // Make sure view is updated when the difficulty changes.
+    noteDisplayDirty = true;
+    notePreviewDirty = true;
+    notePreviewViewportBoundsDirty = true;
+
+    // Make sure the difficulty we selected is in the list of difficulties.
+    currentSongMetadata.playData.difficulties.pushUnique(selectedDifficulty);
+
+    return selectedDifficulty;
+  }
+
+  /**
+   * The instrumental ID which is currently selected.
+   */
+  var currentInstrumentalId(get, set):String;
+
+  function get_currentInstrumentalId():String
+  {
+    var instId:Null<String> = currentSongMetadata.playData.characters.instrumental;
+    if (instId == null || instId == '') instId = (selectedVariation == Constants.DEFAULT_VARIATION) ? '' : selectedVariation;
+    return instId;
+  }
+
+  function set_currentInstrumentalId(value:String):String
+  {
+    return currentSongMetadata.playData.characters.instrumental = value;
+  }
+
   /**
    * RENDER OBJECTS
    */
@@ -1123,6 +1206,9 @@ class ChartEditorState extends HaxeUIState
    */
   var gridPlayhead:FlxSpriteGroup = new FlxSpriteGroup();
 
+  /**
+   * The sprite for the scroll area under
+   */
   var gridPlayheadScrollArea:Null<FlxSprite> = null;
 
   /**
@@ -1203,12 +1289,6 @@ class ChartEditorState extends HaxeUIState
    */
   var playbarNoteSnap:Null<Label> = null;
 
-  /**
-   * The current process that is lerping the scroll position.
-   * Used to cancel the previous lerp if the user scrolls again.
-   */
-  var currentScrollEase:Null<VarTween>;
-
   /**
    * The sprite group containing the note graphics.
    * Only displays a subset of the data from `currentSongChartNoteData`,
@@ -1230,7 +1310,12 @@ class ChartEditorState extends HaxeUIState
    */
   var renderedEvents:FlxTypedSpriteGroup<ChartEditorEventSprite> = new FlxTypedSpriteGroup<ChartEditorEventSprite>();
 
-  var renderedSelectionSquares:FlxTypedSpriteGroup<FlxSprite> = new FlxTypedSpriteGroup<FlxSprite>();
+  var renderedSelectionSquares:FlxTypedSpriteGroup<ChartEditorSelectionSquareSprite> = new FlxTypedSpriteGroup<ChartEditorSelectionSquareSprite>();
+
+  /**
+   * LIFE CYCLE FUNCTIONS
+   */
+  // ==============================
 
   public function new()
   {
@@ -1238,6 +1323,44 @@ class ChartEditorState extends HaxeUIState
     super(CHART_EDITOR_LAYOUT);
   }
 
+  public override function dispatchEvent(event:ScriptEvent):Void
+  {
+    super.dispatchEvent(event);
+
+    // We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it.
+    if (currentPlayerCharacterPlayer != null)
+    {
+      switch (event.type)
+      {
+        case UPDATE:
+          currentPlayerCharacterPlayer.onUpdate(cast event);
+        case SONG_BEAT_HIT:
+          currentPlayerCharacterPlayer.onBeatHit(cast event);
+        case SONG_STEP_HIT:
+          currentPlayerCharacterPlayer.onStepHit(cast event);
+        case NOTE_HIT:
+          currentPlayerCharacterPlayer.onNoteHit(cast event);
+        default: // Continue
+      }
+    }
+
+    if (currentOpponentCharacterPlayer != null)
+    {
+      switch (event.type)
+      {
+        case UPDATE:
+          currentOpponentCharacterPlayer.onUpdate(cast event);
+        case SONG_BEAT_HIT:
+          currentOpponentCharacterPlayer.onBeatHit(cast event);
+        case SONG_STEP_HIT:
+          currentOpponentCharacterPlayer.onStepHit(cast event);
+        case NOTE_HIT:
+          currentOpponentCharacterPlayer.onNoteHit(cast event);
+        default: // Continue
+      }
+    }
+  }
+
   override function create():Void
   {
     // super.create() must be called first, the HaxeUI components get created here.
@@ -1251,7 +1374,7 @@ class ChartEditorState extends HaxeUIState
     fixCamera();
 
     // Get rid of any music from the previous state.
-    FlxG.sound.music.stop();
+    if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
     // Play the welcome music.
     setupWelcomeMusic();
@@ -1260,7 +1383,7 @@ class ChartEditorState extends HaxeUIState
 
     buildBackground();
 
-    ChartEditorThemeHandler.updateTheme(this);
+    this.updateTheme();
 
     buildGrid();
     // buildSpectrogram(audioInstTrack);
@@ -1277,7 +1400,20 @@ class ChartEditorState extends HaxeUIState
 
     refresh();
 
-    ChartEditorDialogHandler.openWelcomeDialog(this, false);
+    this.openWelcomeDialog(false);
+  }
+
+  override function destroy():Void
+  {
+    super.destroy();
+
+    cleanupAutoSave();
+
+    // Hide the mouse cursor on other states.
+    Cursor.hide();
+
+    @:privateAccess
+    ChartEditorNoteSprite.noteFrameCollection = null;
   }
 
   function setupWelcomeMusic()
@@ -1288,13 +1424,13 @@ class ChartEditorState extends HaxeUIState
     // fadeInWelcomeMusic();
   }
 
-  public function fadeInWelcomeMusic():Void
+  function fadeInWelcomeMusic():Void
   {
     this.welcomeMusic.play();
     this.welcomeMusic.fadeIn(4, 0, 1.0);
   }
 
-  public function stopWelcomeMusic():Void
+  function stopWelcomeMusic():Void
   {
     // this.welcomeMusic.fadeOut(4, 0);
     this.welcomeMusic.pause();
@@ -1406,6 +1542,23 @@ class ChartEditorState extends HaxeUIState
     healthIconBF.zIndex = 30;
   }
 
+  function buildNotePreview():Void
+  {
+    var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - PLAYBAR_HEIGHT - GRID_TOP_PAD - GRID_TOP_PAD;
+    notePreview = new ChartEditorNotePreview(height);
+    notePreview.x = 350;
+    notePreview.y = MENU_BAR_HEIGHT + GRID_TOP_PAD;
+    add(notePreview);
+
+    if (notePreviewViewport == null) throw 'ERROR: Tried to build note preview, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().';
+
+    notePreviewViewport.scrollFactor.set(0, 0);
+    add(notePreviewViewport);
+    notePreviewViewport.zIndex = 30;
+
+    setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+  }
+
   function buildSelectionBox():Void
   {
     if (selectionBoxSprite == null) throw 'ERROR: Tried to build selection box, but selectionBoxSprite is null! Check ChartEditorThemeHandler.updateTheme().';
@@ -1438,23 +1591,6 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  function buildNotePreview():Void
-  {
-    var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - PLAYBAR_HEIGHT - GRID_TOP_PAD - GRID_TOP_PAD;
-    notePreview = new ChartEditorNotePreview(height);
-    notePreview.x = 350;
-    notePreview.y = MENU_BAR_HEIGHT + GRID_TOP_PAD;
-    add(notePreview);
-
-    if (notePreviewViewport == null) throw 'ERROR: Tried to build note preview, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().';
-
-    notePreviewViewport.scrollFactor.set(0, 0);
-    add(notePreviewViewport);
-    notePreviewViewport.zIndex = 30;
-
-    setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
-  }
-
   function calculateNotePreviewViewportBounds():FlxRect
   {
     var bounds:FlxRect = new FlxRect();
@@ -1493,12 +1629,6 @@ class ChartEditorState extends HaxeUIState
     return bounds;
   }
 
-  public function switchToCurrentInstrumental():Void
-  {
-    ChartEditorAudioHandler.switchToInstrumental(this, currentInstrumentalId, currentSongMetadata.playData.characters.player,
-      currentSongMetadata.playData.characters.opponent);
-  }
-
   function setNotePreviewViewportBounds(bounds:FlxRect = null):Void
   {
     if (notePreviewViewport == null)
@@ -1635,11 +1765,11 @@ class ChartEditorState extends HaxeUIState
 
     // Add functionality to the menu items.
 
-    addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
-    addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true));
-    addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this));
-    addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
-    addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true));
+    addUIClickListener('menubarItemNewChart', _ -> this.openWelcomeDialog(true));
+    addUIClickListener('menubarItemOpenChart', _ -> this.openBrowseWizard(true));
+    addUIClickListener('menubarItemSaveChartAs', _ -> this.exportAllSongData());
+    addUIClickListener('menubarItemLoadInst', _ -> this.openUploadInstDialog(true));
+    addUIClickListener('menubarItemImportChart', _ -> this.openImportChartDialog('legacy', true));
     addUIClickListener('menubarItemExit', _ -> quitChartEditor());
 
     addUIClickListener('menubarItemUndo', _ -> undoLastCommand());
@@ -1700,6 +1830,8 @@ class ChartEditorState extends HaxeUIState
       }
     });
 
+    addUIClickListener('menubarItemFlipNotes', _ -> performCommand(new FlipNotesCommand(currentNoteSelection)));
+
     addUIClickListener('menubarItemSelectAll', _ -> performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection)));
 
     addUIClickListener('menubarItemSelectInverse', _ -> performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection)));
@@ -1722,10 +1854,10 @@ class ChartEditorState extends HaxeUIState
       currentLiveInputStyle = WASD;
     });
 
-    addUIClickListener('menubarItemAbout', _ -> ChartEditorDialogHandler.openAboutDialog(this));
-    addUIClickListener('menubarItemWelcomeDialog', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
+    addUIClickListener('menubarItemAbout', _ -> this.openAboutDialog());
+    addUIClickListener('menubarItemWelcomeDialog', _ -> this.openWelcomeDialog(true));
 
-    addUIClickListener('menubarItemUserGuide', _ -> ChartEditorDialogHandler.openUserGuideDialog(this));
+    addUIClickListener('menubarItemUserGuide', _ -> this.openUserGuideDialog());
 
     addUIChangeListener('menubarItemDownscroll', event -> isViewDownscroll = event.value);
     setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll);
@@ -1748,8 +1880,8 @@ class ChartEditorState extends HaxeUIState
 
     addUIClickListener('menubarItemPlayPause', _ -> toggleAudioPlayback());
 
-    addUIClickListener('menubarItemLoadInstrumental', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
-    addUIClickListener('menubarItemLoadVocals', _ -> ChartEditorDialogHandler.openUploadVocalsDialog(this, true));
+    addUIClickListener('menubarItemLoadInstrumental', _ -> this.openUploadInstDialog(true));
+    addUIClickListener('menubarItemLoadVocals', _ -> this.openUploadVocalsDialog(true));
 
     addUIChangeListener('menubarItemMetronomeEnabled', event -> isMetronomeEnabled = event.value);
     setUICheckboxSelected('menubarItemMetronomeEnabled', isMetronomeEnabled);
@@ -1795,18 +1927,12 @@ class ChartEditorState extends HaxeUIState
       });
     }
 
-    addUIChangeListener('menubarItemToggleToolboxDifficulty',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxMetadata',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxNotes',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxEvents',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxPlayerPreview',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxOpponentPreview',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxDifficulty', event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxMetadata', event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxNotes', event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxEvents', event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxPlayerPreview', event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxOpponentPreview', event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value));
 
     // TODO: Pass specific HaxeUI components to add context menus to them.
     registerContextMenu(null, Paths.ui('chart-editor/context/test'));
@@ -1838,38 +1964,8 @@ class ChartEditorState extends HaxeUIState
   }
 
   /**
-   * Called after 5 minutes without saving.
+   * UPDATE FUNCTIONS
    */
-  function autoSave():Void
-  {
-    saveDataDirty = false;
-
-    // Auto-save the chart.
-
-    #if html5
-    // Auto-save to local storage.
-    #else
-    // Auto-save to temp file.
-    ChartEditorImportExportHandler.exportAllSongData(this, true);
-    #end
-  }
-
-  function onWindowClose(exitCode:Int):Void
-  {
-    trace('Window exited with exit code: $exitCode');
-    trace('Should save chart? $saveDataDirty');
-
-    if (saveDataDirty)
-    {
-      ChartEditorImportExportHandler.exportAllSongData(this, true);
-    }
-  }
-
-  function cleanupAutoSave():Void
-  {
-    WindowUtil.windowExit.remove(onWindowClose);
-  }
-
   public override function update(elapsed:Float):Void
   {
     // Override F4 behavior to include the autosave.
@@ -1909,12 +2005,6 @@ class ChartEditorState extends HaxeUIState
     #end
   }
 
-  function handleQuickWatch():Void
-  {
-    FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
-    FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
-  }
-
   /**
    * Beat hit while the song is playing.
    */
@@ -1952,9 +2042,451 @@ class ChartEditorState extends HaxeUIState
     return true;
   }
 
+  /**
+   * UPDATE HANDLERS
+   */
+  // ====================
+
+  /**
+   * Handle syncronizing the conductor with the music playback.
+   */
+  function handleMusicPlayback():Void
+  {
+    if (audioInstTrack != null && audioInstTrack.playing)
+    {
+      if (FlxG.mouse.pressedMiddle)
+      {
+        // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat!
+
+        var oldStepTime:Float = Conductor.currentStepTime;
+        var oldSongPosition:Float = Conductor.songPosition;
+        Conductor.update(audioInstTrack.time);
+        handleHitsounds(oldSongPosition, Conductor.songPosition);
+        // Resync vocals.
+        if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+        {
+          audioVocalTrackGroup.time = audioInstTrack.time;
+        }
+        var diffStepTime:Float = Conductor.currentStepTime - oldStepTime;
+
+        // Move the playhead.
+        playheadPositionInPixels += diffStepTime * GRID_SIZE;
+
+        // We don't move the song to scroll position, or update the note sprites.
+      }
+      else
+      {
+        // Else, move the entire view.
+        var oldSongPosition:Float = Conductor.songPosition;
+        Conductor.update(audioInstTrack.time);
+        handleHitsounds(oldSongPosition, Conductor.songPosition);
+        // Resync vocals.
+        if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+        {
+          audioVocalTrackGroup.time = audioInstTrack.time;
+        }
+
+        // We need time in fractional steps here to allow the song to actually play.
+        // Also account for a potentially offset playhead.
+        scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels;
+
+        // DO NOT move song to scroll position here specifically.
+
+        // We need to update the note sprites.
+        noteDisplayDirty = true;
+
+        // Update the note preview viewport box.
+        setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+      }
+    }
+
+    if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen)
+    {
+      toggleAudioPlayback();
+    }
+  }
+
+  /**
+   * Handle the playback of hitsounds.
+   */
+  function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void
+  {
+    if (!hitsoundsEnabled) return;
+
+    // Assume notes are sorted by time.
+    for (noteData in currentSongChartNoteData)
+    {
+      // Check for notes between the old and new song positions.
+
+      if (noteData.time < oldSongPosition) // Note is in the past.
+        continue;
+
+      if (noteData.time > newSongPosition) // Note is in the future.
+        return; // Assume all notes are also in the future.
+
+      // Note was just hit.
+
+      // Character preview.
+
+      // NoteScriptEvent takes a sprite, ehe. Need to rework that.
+      var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault());
+      tempNote.noteData = noteData;
+      tempNote.scrollFactor.set(0, 0);
+      var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, tempNote, 1, true);
+      dispatchEvent(event);
+
+      // Calling event.cancelEvent() skips all the other logic! Neat!
+      if (event.eventCanceled) continue;
+
+      // Hitsounds.
+      switch (noteData.getStrumlineIndex())
+      {
+        case 0: // Player
+          if (hitsoundsEnabledPlayer) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'));
+        case 1: // Opponent
+          if (hitsoundsEnabledOpponent) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'));
+      }
+    }
+  }
+
+  /**
+   * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`.
+   */
+  function handleNoteDisplay():Void
+  {
+    if (noteDisplayDirty)
+    {
+      noteDisplayDirty = false;
+
+      // Update for whether downscroll is enabled.
+      renderedNotes.flipX = (isViewDownscroll);
+
+      // Calculate the top and bottom of the view area.
+      var viewAreaTopPixels:Float = MENU_BAR_HEIGHT;
+      var visibleGridHeightPixels:Float = FlxG.height - MENU_BAR_HEIGHT - PLAYBAR_HEIGHT; // The area underneath the menu bar and playbar is not visible.
+      var viewAreaBottomPixels:Float = viewAreaTopPixels + visibleGridHeightPixels;
+
+      // Remove notes that are no longer visible and list the ones that are.
+      var displayedNoteData:Array<SongNoteData> = [];
+      for (noteSprite in renderedNotes.members)
+      {
+        if (noteSprite == null || noteSprite.noteData == null || !noteSprite.exists || !noteSprite.visible) continue;
+
+        // Resolve an issue where dragging an event too far would cause it to be hidden.
+        var isSelectedAndDragged = currentNoteSelection.fastContains(noteSprite.noteData) && (dragTargetCurrentStep != 0);
+
+        if ((noteSprite.isNoteVisible(viewAreaBottomPixels, viewAreaTopPixels)
+          && currentSongChartNoteData.fastContains(noteSprite.noteData))
+          || isSelectedAndDragged)
+        {
+          // Note is already displayed and should remain displayed.
+          displayedNoteData.push(noteSprite.noteData);
+
+          // Update the note sprite's position.
+          noteSprite.updateNotePosition(renderedNotes);
+        }
+        else
+        {
+          // This sprite is off-screen or was deleted.
+          // Kill the note sprite and recycle it.
+          noteSprite.noteData = null;
+        }
+      }
+      // Sort the note data array, using an algorithm that is fast on nearly-sorted data.
+      // We need this sorted to optimize indexing later.
+      displayedNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING));
+
+      var displayedHoldNoteData:Array<SongNoteData> = [];
+      for (holdNoteSprite in renderedHoldNotes.members)
+      {
+        if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue;
+
+        if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
+        {
+          holdNoteSprite.kill();
+        }
+        else if (!currentSongChartNoteData.fastContains(holdNoteSprite.noteData) || holdNoteSprite.noteData.length == 0)
+        {
+          // This hold note was deleted.
+          // Kill the hold note sprite and recycle it.
+          holdNoteSprite.kill();
+        }
+        else if (displayedHoldNoteData.fastContains(holdNoteSprite.noteData))
+        {
+          // This hold note is a duplicate.
+          // Kill the hold note sprite and recycle it.
+          holdNoteSprite.kill();
+        }
+        else
+        {
+          displayedHoldNoteData.push(holdNoteSprite.noteData);
+          // Update the event sprite's position.
+          holdNoteSprite.updateHoldNotePosition(renderedNotes);
+        }
+      }
+      // Sort the note data array, using an algorithm that is fast on nearly-sorted data.
+      // We need this sorted to optimize indexing later.
+      displayedHoldNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING));
+
+      // Remove events that are no longer visible and list the ones that are.
+      var displayedEventData:Array<SongEventData> = [];
+      for (eventSprite in renderedEvents.members)
+      {
+        if (eventSprite == null || eventSprite.eventData == null || !eventSprite.exists || !eventSprite.visible) continue;
+
+        // Resolve an issue where dragging an event too far would cause it to be hidden.
+        var isSelectedAndDragged = currentEventSelection.fastContains(eventSprite.eventData) && (dragTargetCurrentStep != 0);
+
+        if ((eventSprite.isEventVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD)
+          && currentSongChartEventData.fastContains(eventSprite.eventData))
+          || isSelectedAndDragged)
+        {
+          // Event is already displayed and should remain displayed.
+          displayedEventData.push(eventSprite.eventData);
+
+          // Update the event sprite's position.
+          eventSprite.updateEventPosition(renderedEvents);
+        }
+        else
+        {
+          // This event was deleted.
+          // Kill the event sprite and recycle it.
+          eventSprite.eventData = null;
+        }
+      }
+      // Sort the note data array, using an algorithm that is fast on nearly-sorted data.
+      // We need this sorted to optimize indexing later.
+      displayedEventData.insertionSort(SortUtil.eventDataByTime.bind(FlxSort.ASCENDING));
+
+      // Let's try testing only notes within a certain range of the view area.
+      // TODO: I don't think this messes up really long sustains, does it?
+      var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.measureLengthMs * 2); // Is 2 measures enough?
+      var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.measureLengthMs * 2); // Is 2 measures enough?
+
+      // Add notes that are now visible.
+      for (noteData in currentSongChartNoteData)
+      {
+        // Remember if we are already displaying this note.
+        if (noteData == null) continue;
+        // Check if we are outside a broad range around the view area.
+        if (noteData.time < viewAreaTopMs || noteData.time > viewAreaBottomMs) continue;
+
+        if (displayedNoteData.fastContains(noteData))
+        {
+          continue;
+        }
+
+        if (!ChartEditorNoteSprite.wouldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData,
+          renderedNotes)) continue; // Else, this note is visible and we need to render it!
+
+        // Get a note sprite from the pool.
+        // If we can reuse a deleted note, do so.
+        // If a new note is needed, call buildNoteSprite.
+        var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this));
+        // trace('Creating new Note... (${renderedNotes.members.length})');
+        noteSprite.parentState = this;
+
+        // The note sprite handles animation playback and positioning.
+        noteSprite.noteData = noteData;
+        noteSprite.overrideStepTime = null;
+        noteSprite.overrideData = null;
+
+        // Setting note data resets the position relative to the group!
+        // If we don't update the note position AFTER setting the note data, the note will be rendered offscreen at y=5000.
+        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)
+        {
+          var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(() -> new ChartEditorHoldNoteSprite(this));
+          // trace('Creating new HoldNote... (${renderedHoldNotes.members.length})');
+
+          var noteLengthPixels:Float = noteSprite.noteData.getStepLength() * GRID_SIZE;
+
+          holdNoteSprite.noteData = noteSprite.noteData;
+          holdNoteSprite.noteDirection = noteSprite.noteData.getDirection();
+
+          holdNoteSprite.setHeightDirectly(noteLengthPixels);
+
+          holdNoteSprite.updateHoldNotePosition(renderedHoldNotes);
+        }
+      }
+
+      // Add events that are now visible.
+      for (eventData in currentSongChartEventData)
+      {
+        // Remember if we are already displaying this event.
+        if (displayedEventData.indexOf(eventData) != -1)
+        {
+          continue;
+        }
+
+        if (!ChartEditorEventSprite.wouldEventBeVisible(viewAreaBottomPixels, viewAreaTopPixels, eventData, renderedNotes)) continue;
+
+        // Else, this event is visible and we need to render it!
+
+        // Get an event sprite from the pool.
+        // If we can reuse a deleted event, do so.
+        // If a new event is needed, call buildEventSprite.
+        var eventSprite:ChartEditorEventSprite = renderedEvents.recycle(() -> new ChartEditorEventSprite(this), false, true);
+        eventSprite.parentState = this;
+        trace('Creating new Event... (${renderedEvents.members.length})');
+
+        // The event sprite handles animation playback and positioning.
+        eventSprite.eventData = eventData;
+        eventSprite.overrideStepTime = null;
+
+        // Setting event data resets position relative to the grid so we fix that.
+        eventSprite.x += renderedEvents.x;
+        eventSprite.y += renderedEvents.y;
+      }
+
+      // Add hold notes that have been made visible (but not their parents)
+      for (noteData in currentSongChartNoteData)
+      {
+        // Is the note a hold note?
+        if (noteData == null || noteData.length <= 0) continue;
+
+        // Is the hold note rendered already?
+        if (displayedHoldNoteData.indexOf(noteData) != -1) continue;
+
+        // Is the hold note offscreen?
+        if (!ChartEditorHoldNoteSprite.wouldHoldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData, renderedHoldNotes)) continue;
+
+        // Hold note should be rendered.
+        var holdNoteFactory = function() {
+          // TODO: Print some kind of warning if `renderedHoldNotes.members` is too high?
+          return new ChartEditorHoldNoteSprite(this);
+        }
+        var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(holdNoteFactory);
+
+        var noteLengthPixels:Float = noteData.getStepLength() * GRID_SIZE;
+
+        holdNoteSprite.noteData = noteData;
+        holdNoteSprite.noteDirection = noteData.getDirection();
+
+        holdNoteSprite.setHeightDirectly(noteLengthPixels);
+
+        holdNoteSprite.updateHoldNotePosition(renderedHoldNotes);
+
+        displayedHoldNoteData.push(noteData);
+      }
+
+      // Destroy all existing selection squares.
+      for (member in renderedSelectionSquares.members)
+      {
+        // Killing the sprite is cheap because we can recycle it.
+        member.kill();
+      }
+
+      // Readd selection squares for selected notes.
+      // Recycle selection squares if possible.
+      for (noteSprite in renderedNotes.members)
+      {
+        // TODO: Handle selection of hold notes.
+        if (isNoteSelected(noteSprite.noteData))
+        {
+          // Determine if the note is being dragged and offset the vertical position accordingly.
+          if (dragTargetCurrentStep != 0.0)
+          {
+            var stepTime:Float = (noteSprite.noteData == null) ? 0.0 : noteSprite.noteData.getStepTime();
+            // Update the note's "ghost" step time.
+            noteSprite.overrideStepTime = (stepTime + dragTargetCurrentStep).clamp(0, songLengthInSteps - (1 * noteSnapRatio));
+            // Then reapply the note sprite's position relative to the grid.
+            noteSprite.updateNotePosition(renderedNotes);
+          }
+          else
+          {
+            if (noteSprite.overrideStepTime != null)
+            {
+              // Reset the note's "ghost" step time.
+              noteSprite.overrideStepTime = null;
+              // Then reapply the note sprite's position relative to the grid.
+              noteSprite.updateNotePosition(renderedNotes);
+            }
+          }
+
+          // Determine if the note is being dragged and offset the horizontal position accordingly.
+          if (dragTargetCurrentColumn != 0)
+          {
+            var data:Int = (noteSprite.noteData == null) ? 0 : noteSprite.noteData.data;
+            // Update the note's "ghost" column.
+            noteSprite.overrideData = gridColumnToNoteData((noteDataToGridColumn(data) + dragTargetCurrentColumn).clamp(0,
+              ChartEditorState.STRUMLINE_SIZE * 2 - 1));
+            // Then reapply the note sprite's position relative to the grid.
+            noteSprite.updateNotePosition(renderedNotes);
+          }
+          else
+          {
+            if (noteSprite.overrideData != null)
+            {
+              // Reset the note's "ghost" column.
+              noteSprite.overrideData = null;
+              // Then reapply the note sprite's position relative to the grid.
+              noteSprite.updateNotePosition(renderedNotes);
+            }
+          }
+
+          // Then, render the selection square.
+          var selectionSquare:ChartEditorSelectionSquareSprite = renderedSelectionSquares.recycle(buildSelectionSquare);
+
+          // Set the position and size (because we might be recycling one with bad values).
+          selectionSquare.noteData = noteSprite.noteData;
+          selectionSquare.eventData = null;
+          selectionSquare.x = noteSprite.x;
+          selectionSquare.y = noteSprite.y;
+          selectionSquare.width = GRID_SIZE;
+          selectionSquare.height = GRID_SIZE;
+        }
+      }
+
+      for (eventSprite in renderedEvents.members)
+      {
+        if (isEventSelected(eventSprite.eventData))
+        {
+          // Determine if the note is being dragged and offset the position accordingly.
+          if (dragTargetCurrentStep > 0 || dragTargetCurrentColumn > 0)
+          {
+            var stepTime = (eventSprite.eventData == null) ? 0 : eventSprite.eventData.getStepTime();
+            eventSprite.overrideStepTime = (stepTime + dragTargetCurrentStep).clamp(0, songLengthInSteps);
+            // Then reapply the note sprite's position relative to the grid.
+            eventSprite.updateEventPosition(renderedEvents);
+          }
+          else
+          {
+            if (eventSprite.overrideStepTime != null)
+            {
+              // Reset the note's "ghost" column.
+              eventSprite.overrideStepTime = null;
+              // Then reapply the note sprite's position relative to the grid.
+              eventSprite.updateEventPosition(renderedEvents);
+            }
+          }
+
+          // Then, render the selection square.
+          var selectionSquare:ChartEditorSelectionSquareSprite = renderedSelectionSquares.recycle(buildSelectionSquare);
+
+          // Set the position and size (because we might be recycling one with bad values).
+          selectionSquare.noteData = null;
+          selectionSquare.eventData = eventSprite.eventData;
+          selectionSquare.x = eventSprite.x;
+          selectionSquare.y = eventSprite.y;
+          selectionSquare.width = eventSprite.width;
+          selectionSquare.height = eventSprite.height;
+        }
+      }
+
+      // Sort the notes DESCENDING. This keeps the sustain behind the associated note.
+      renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
+
+      // Sort the events DESCENDING. This keeps the sustain behind the associated note.
+      renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
+    }
+  }
+
   /**
    * Handle keybinds for scrolling the chart editor grid.
-  **/
+   */
   function handleScrollKeybinds():Void
   {
     // Don't scroll when the cursor is over the UI, unless a playbar button (the << >> ones) is pressed.
@@ -2128,6 +2660,9 @@ class ChartEditorState extends HaxeUIState
     if (shouldPause) stopAudioPlayback();
   }
 
+  /**
+   * Handle changing the note snapping level.
+   */
   function handleSnap():Void
   {
     if (currentLiveInputStyle == None)
@@ -2144,9 +2679,6 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  var dragLengthCurrent:Float = 0;
-  var stretchySounds:Bool = false;
-
   /**
    * Handle display of the mouse cursor.
    */
@@ -2157,9 +2689,13 @@ class ChartEditorState extends HaxeUIState
     if (FlxG.mouse.justReleased) FlxG.sound.play(Paths.sound("chartingSounds/ClickUp"));
 
     // Note: If a menu is open in HaxeUI, don't handle cursor behavior.
-    var shouldHandleCursor:Bool = !isCursorOverHaxeUI || (selectionBoxStartPos != null);
+    var shouldHandleCursor:Bool = !isCursorOverHaxeUI
+      || (selectionBoxStartPos != null)
+      || (dragTargetNote != null || dragTargetEvent != null);
     var eventColumn:Int = (STRUMLINE_SIZE * 2 + 1) - 1;
 
+    trace('shouldHandleCursor: $shouldHandleCursor');
+
     if (shouldHandleCursor)
     {
       // Over the course of this big conditional block,
@@ -2180,6 +2716,8 @@ class ChartEditorState extends HaxeUIState
           || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2))
             || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)));
 
+      var overlapsSelection:Bool = FlxG.mouse.overlaps(renderedSelectionSquares);
+
       if (FlxG.mouse.justPressed)
       {
         if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea))
@@ -2197,14 +2735,10 @@ class ChartEditorState extends HaxeUIState
           // Drawing selection box.
           targetCursorMode = Crosshair;
         }
-        else
+        else if (overlapsSelection)
         {
-          // Deselect all items.
-          if (currentNoteSelection.length > 0 || currentEventSelection.length > 0)
-          {
-            trace('Clicked outside grid, deselecting all items.');
-            performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
-          }
+          // Do nothing
+          trace('Clicked on a selected note!');
         }
       }
 
@@ -2237,25 +2771,8 @@ class ChartEditorState extends HaxeUIState
       var cursorSnappedMs:Float = Conductor.getStepTimeInMs(cursorSnappedStep);
 
       // The direction value for the column at the cursor.
-      var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE);
-      if (cursorColumn < 0) cursorColumn = 0;
-      if (cursorColumn >= (STRUMLINE_SIZE * 2 + 1 - 1))
-      {
-        // Don't invert the event column.
-        cursorColumn = (STRUMLINE_SIZE * 2 + 1 - 1);
-      }
-      else
-      {
-        // Invert player and opponent columns.
-        if (cursorColumn >= STRUMLINE_SIZE)
-        {
-          cursorColumn -= STRUMLINE_SIZE;
-        }
-        else
-        {
-          cursorColumn += STRUMLINE_SIZE;
-        }
-      }
+      var cursorGridPos:Int = Math.floor(cursorX / GRID_SIZE);
+      var cursorColumn:Int = gridColumnToNoteData(cursorGridPos);
 
       if (selectionBoxStartPos != null)
       {
@@ -2491,6 +3008,108 @@ class ChartEditorState extends HaxeUIState
         scrollPositionInPixels = clickedPosInPixels;
         moveSongToScrollPosition();
       }
+      else if (dragTargetNote != null || dragTargetEvent != null)
+      {
+        if (FlxG.mouse.justReleased)
+        {
+          // Perform the actual drag operation.
+          var dragDistanceSteps:Float = dragTargetCurrentStep;
+          var dragDistanceMs:Float = 0;
+          if (dragTargetNote != null && dragTargetNote.noteData != null)
+          {
+            dragDistanceMs = Conductor.getStepTimeInMs(dragTargetNote.noteData.getStepTime() + dragDistanceSteps) - dragTargetNote.noteData.time;
+          }
+          else if (dragTargetEvent != null && dragTargetEvent.eventData != null)
+          {
+            dragDistanceMs = Conductor.getStepTimeInMs(dragTargetEvent.eventData.getStepTime() + dragDistanceSteps) - dragTargetEvent.eventData.time;
+          }
+          var dragDistanceColumns:Int = dragTargetCurrentColumn;
+
+          if (currentNoteSelection.length > 0 && currentEventSelection.length > 0)
+          {
+            // Both notes and events are selected.
+            performCommand(new MoveItemsCommand(currentNoteSelection, currentEventSelection, dragDistanceMs, dragDistanceColumns));
+          }
+          else if (currentNoteSelection.length > 0)
+          {
+            // Only notes are selected.
+            performCommand(new MoveNotesCommand(currentNoteSelection, dragDistanceMs, dragDistanceColumns));
+          }
+          else if (currentEventSelection.length > 0)
+          {
+            // Only events are selected.
+            performCommand(new MoveEventsCommand(currentEventSelection, dragDistanceMs));
+          }
+
+          // Finished dragging. Release the note at the new position.
+          dragTargetNote = null;
+          dragTargetEvent = null;
+
+          noteDisplayDirty = true;
+
+          dragTargetCurrentStep = 0;
+          dragTargetCurrentColumn = 0;
+        }
+        else
+        {
+          // Player is clicking and holding on a selected note or event to move the selection around.
+          targetCursorMode = Grabbing;
+
+          // Scroll the screen if the mouse is above or below the grid.
+          if (FlxG.mouse.screenY < MENU_BAR_HEIGHT)
+          {
+            // Scroll up.
+            trace('Scroll up!');
+            var diff:Float = MENU_BAR_HEIGHT - FlxG.mouse.screenY;
+            scrollPositionInPixels -= diff * 0.5; // Too fast!
+            moveSongToScrollPosition();
+          }
+          else if (FlxG.mouse.screenY > (playbarHeadLayout?.y ?? 0.0))
+          {
+            // Scroll down.
+            trace('Scroll down!');
+            var diff:Float = FlxG.mouse.screenY - (playbarHeadLayout?.y ?? 0.0);
+            scrollPositionInPixels += diff * 0.5; // Too fast!
+            moveSongToScrollPosition();
+          }
+
+          // Calculate distance between the position dragged to and the original position.
+          var stepTime:Float = 0;
+          if (dragTargetNote != null && dragTargetNote.noteData != null)
+          {
+            stepTime = dragTargetNote.noteData.getStepTime();
+          }
+          else if (dragTargetEvent != null && dragTargetEvent.eventData != null)
+          {
+            stepTime = dragTargetEvent.eventData.getStepTime();
+          }
+          var dragDistanceSteps:Float = Conductor.getTimeInSteps(cursorSnappedMs).clamp(0, songLengthInSteps - (1 * noteSnapRatio)) - stepTime;
+          var data:Int = 0;
+          var noteGridPos:Int = 0;
+          if (dragTargetNote != null && dragTargetNote.noteData != null)
+          {
+            data = dragTargetNote.noteData.data;
+            noteGridPos = noteDataToGridColumn(data);
+          }
+          else if (dragTargetEvent != null)
+          {
+            data = ChartEditorState.STRUMLINE_SIZE * 2 + 1;
+          }
+          var dragDistanceColumns:Int = cursorGridPos - noteGridPos;
+
+          if (dragTargetCurrentStep != dragDistanceSteps || dragTargetCurrentColumn != dragDistanceColumns)
+          {
+            // Play a sound as we drag.
+            this.playSound(Paths.sound('chartingSounds/noteLay'));
+
+            trace('Dragged ${dragDistanceColumns} X and ${dragDistanceSteps} Y.');
+            dragTargetCurrentStep = dragDistanceSteps;
+            dragTargetCurrentColumn = dragDistanceColumns;
+
+            noteDisplayDirty = true;
+          }
+        }
+      }
       else if (currentPlaceNoteData != null)
       {
         // Handle extending the note as you drag.
@@ -2507,7 +3126,7 @@ class ChartEditorState extends HaxeUIState
             if (dragLengthCurrent != dragLengthSteps)
             {
               stretchySounds = !stretchySounds;
-              ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/stretch' + (stretchySounds ? '1' : '2') + '_UI'));
+              this.playSound(Paths.sound('chartingSounds/stretch' + (stretchySounds ? '1' : '2') + '_UI'));
 
               dragLengthCurrent = dragLengthSteps;
             }
@@ -2530,7 +3149,7 @@ class ChartEditorState extends HaxeUIState
         {
           if (dragLengthSteps > 0)
           {
-            ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
+            this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
             // Apply the new length.
             performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs));
           }
@@ -2596,19 +3215,37 @@ class ChartEditorState extends HaxeUIState
             {
               if (highlightedNote != null && highlightedNote.noteData != null)
               {
-                // Click a note to select it.
-                performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
+                if (isNoteSelected(highlightedNote.noteData))
+                {
+                  // Clicked a selected event, start dragging.
+                  trace('Ready to drag!');
+                  dragTargetNote = highlightedNote;
+                }
+                else
+                {
+                  // If you click an unselected note, and aren't holding Control, deselect everything else.
+                  performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
+                }
               }
               else if (highlightedEvent != null && highlightedEvent.eventData != null)
               {
-                // Click an event to select it.
-                performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
+                if (isEventSelected(highlightedEvent.eventData))
+                {
+                  // Clicked a selected event, start dragging.
+                  trace('Ready to drag!');
+                  dragTargetEvent = highlightedEvent;
+                }
+                else
+                {
+                  // If you click an unselected event, and aren't holding Control, deselect everything else.
+                  performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
+                }
               }
               else
               {
                 // Click a blank space to place a note and select it.
 
-                if (cursorColumn == eventColumn)
+                if (cursorGridPos == eventColumn)
                 {
                   // Create an event and place it in the chart.
                   // TODO: Figure out configuring event data.
@@ -2671,12 +3308,13 @@ class ChartEditorState extends HaxeUIState
           }
         }
 
+        var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null;
         // Handle grid cursor.
-        if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
+        if (overlapsGrid && !isOrWillSelect && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
         {
           // Indicate that we can place a note here.
 
-          if (cursorColumn == eventColumn)
+          if (cursorGridPos == eventColumn)
           {
             if (gridGhostNote != null) gridGhostNote.visible = false;
             if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false;
@@ -2732,6 +3370,10 @@ class ChartEditorState extends HaxeUIState
       {
         if (FlxG.mouse.pressed)
         {
+          if (overlapsSelection)
+          {
+            targetCursorMode = Grabbing;
+          }
           if (overlapsSelectionBorder)
           {
             targetCursorMode = Crosshair;
@@ -2747,6 +3389,10 @@ class ChartEditorState extends HaxeUIState
           {
             targetCursorMode = Pointer;
           }
+          else if (overlapsSelection)
+          {
+            targetCursorMode = Pointer;
+          }
           else if (overlapsSelectionBorder)
           {
             targetCursorMode = Crosshair;
@@ -2772,329 +3418,188 @@ class ChartEditorState extends HaxeUIState
   }
 
   /**
-   * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`.
+   * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status.
+   * Does not handle onClick ACTIONS of the menubar.
    */
-  function handleNoteDisplay():Void
+  function handleMenubar():Void
   {
-    if (noteDisplayDirty)
+    if (commandHistoryDirty)
     {
-      noteDisplayDirty = false;
+      commandHistoryDirty = false;
 
-      // Update for whether downscroll is enabled.
-      renderedNotes.flipX = (isViewDownscroll);
+      // Update the Undo and Redo buttons.
+      var undoButton:Null<MenuItem> = findComponent('menubarItemUndo', MenuItem);
 
-      // Calculate the top and bottom of the view area.
-      var viewAreaTopPixels:Float = MENU_BAR_HEIGHT;
-      var visibleGridHeightPixels:Float = FlxG.height - MENU_BAR_HEIGHT - PLAYBAR_HEIGHT; // The area underneath the menu bar and playbar is not visible.
-      var viewAreaBottomPixels:Float = viewAreaTopPixels + visibleGridHeightPixels;
-
-      // Remove notes that are no longer visible and list the ones that are.
-      var displayedNoteData:Array<SongNoteData> = [];
-      for (noteSprite in renderedNotes.members)
+      if (undoButton != null)
       {
-        if (noteSprite == null || noteSprite.noteData == null || !noteSprite.exists || !noteSprite.visible) continue;
-
-        if (!noteSprite.isNoteVisible(viewAreaBottomPixels, viewAreaTopPixels))
+        if (undoHistory.length == 0)
         {
-          // This sprite is off-screen.
-          // Kill the note sprite and recycle it.
-          noteSprite.noteData = null;
-        }
-        else if (!currentSongChartNoteData.fastContains(noteSprite.noteData))
-        {
-          // This note was deleted.
-          // Kill the note sprite and recycle it.
-          noteSprite.noteData = null;
+          // Disable the Undo button.
+          undoButton.disabled = true;
+          undoButton.text = 'Undo';
         }
         else
         {
-          // Note is already displayed and should remain displayed.
-          displayedNoteData.push(noteSprite.noteData);
-
-          // Update the note sprite's position.
-          noteSprite.updateNotePosition(renderedNotes);
+          // Change the label to the last command.
+          undoButton.disabled = false;
+          undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}';
         }
       }
-      // Sort the note data array, using an algorithm that is fast on nearly-sorted data.
-      // We need this sorted to optimize indexing later.
-      displayedNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING));
-
-      var displayedHoldNoteData:Array<SongNoteData> = [];
-      for (holdNoteSprite in renderedHoldNotes.members)
+      else
       {
-        if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue;
+        trace('undoButton is null');
+      }
 
-        if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
+      var redoButton:Null<MenuItem> = findComponent('menubarItemRedo', MenuItem);
+
+      if (redoButton != null)
+      {
+        if (redoHistory.length == 0)
         {
-          holdNoteSprite.kill();
-        }
-        else if (!currentSongChartNoteData.fastContains(holdNoteSprite.noteData) || holdNoteSprite.noteData.length == 0)
-        {
-          // This hold note was deleted.
-          // Kill the hold note sprite and recycle it.
-          holdNoteSprite.kill();
-        }
-        else if (displayedHoldNoteData.fastContains(holdNoteSprite.noteData))
-        {
-          // This hold note is a duplicate.
-          // Kill the hold note sprite and recycle it.
-          holdNoteSprite.kill();
+          // Disable the Redo button.
+          redoButton.disabled = true;
+          redoButton.text = 'Redo';
         }
         else
         {
-          displayedHoldNoteData.push(holdNoteSprite.noteData);
-          // Update the event sprite's position.
-          holdNoteSprite.updateHoldNotePosition(renderedNotes);
+          // Change the label to the last command.
+          redoButton.disabled = false;
+          redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}';
         }
       }
-      // Sort the note data array, using an algorithm that is fast on nearly-sorted data.
-      // We need this sorted to optimize indexing later.
-      displayedHoldNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING));
-
-      // Remove events that are no longer visible and list the ones that are.
-      var displayedEventData:Array<SongEventData> = [];
-      for (eventSprite in renderedEvents.members)
+      else
       {
-        if (eventSprite == null || eventSprite.eventData == null || !eventSprite.exists || !eventSprite.visible) continue;
-
-        if (!eventSprite.isEventVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
-        {
-          // This sprite is off-screen.
-          // Kill the event sprite and recycle it.
-          eventSprite.eventData = null;
-        }
-        else if (!currentSongChartEventData.fastContains(eventSprite.eventData))
-        {
-          // This event was deleted.
-          // Kill the event sprite and recycle it.
-          eventSprite.eventData = null;
-        }
-        else
-        {
-          // Event is already displayed and should remain displayed.
-          displayedEventData.push(eventSprite.eventData);
-
-          // Update the event sprite's position.
-          eventSprite.updateEventPosition(renderedEvents);
-        }
+        trace('redoButton is null');
       }
-      // Sort the note data array, using an algorithm that is fast on nearly-sorted data.
-      // We need this sorted to optimize indexing later.
-      displayedEventData.insertionSort(SortUtil.eventDataByTime.bind(FlxSort.ASCENDING));
-
-      // Let's try testing only notes within a certain range of the view area.
-      // TODO: I don't think this messes up really long sustains, does it?
-      var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.measureLengthMs * 2); // Is 2 measures enough?
-      var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.measureLengthMs * 2); // Is 2 measures enough?
-
-      // Add notes that are now visible.
-      for (noteData in currentSongChartNoteData)
-      {
-        // Remember if we are already displaying this note.
-        if (noteData == null) continue;
-        // Check if we are outside a broad range around the view area.
-        if (noteData.time < viewAreaTopMs || noteData.time > viewAreaBottomMs) continue;
-
-        if (displayedNoteData.fastContains(noteData))
-        {
-          continue;
-        }
-
-        if (!ChartEditorNoteSprite.wouldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData,
-          renderedNotes)) continue; // Else, this note is visible and we need to render it!
-
-        // Get a note sprite from the pool.
-        // If we can reuse a deleted note, do so.
-        // If a new note is needed, call buildNoteSprite.
-        var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this));
-        // trace('Creating new Note... (${renderedNotes.members.length})');
-        noteSprite.parentState = this;
-
-        // The note sprite handles animation playback and positioning.
-        noteSprite.noteData = noteData;
-
-        // Setting note data resets the position relative to the group!
-        // If we don't update the note position AFTER setting the note data, the note will be rendered offscreen at y=5000.
-        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)
-        {
-          var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(() -> new ChartEditorHoldNoteSprite(this));
-          // trace('Creating new HoldNote... (${renderedHoldNotes.members.length})');
-
-          var noteLengthPixels:Float = noteSprite.noteData.getStepLength() * GRID_SIZE;
-
-          holdNoteSprite.noteData = noteSprite.noteData;
-          holdNoteSprite.noteDirection = noteSprite.noteData.getDirection();
-
-          holdNoteSprite.setHeightDirectly(noteLengthPixels);
-
-          holdNoteSprite.updateHoldNotePosition(renderedHoldNotes);
-        }
-      }
-
-      // Add events that are now visible.
-      for (eventData in currentSongChartEventData)
-      {
-        // Remember if we are already displaying this event.
-        if (displayedEventData.indexOf(eventData) != -1)
-        {
-          continue;
-        }
-
-        if (!ChartEditorEventSprite.wouldEventBeVisible(viewAreaBottomPixels, viewAreaTopPixels, eventData, renderedNotes)) continue;
-
-        // Else, this event is visible and we need to render it!
-
-        // Get an event sprite from the pool.
-        // If we can reuse a deleted event, do so.
-        // If a new event is needed, call buildEventSprite.
-        var eventSprite:ChartEditorEventSprite = renderedEvents.recycle(() -> new ChartEditorEventSprite(this), false, true);
-        eventSprite.parentState = this;
-        trace('Creating new Event... (${renderedEvents.members.length})');
-
-        // The event sprite handles animation playback and positioning.
-        eventSprite.eventData = eventData;
-
-        // Setting event data resets position relative to the grid so we fix that.
-        eventSprite.x += renderedEvents.x;
-        eventSprite.y += renderedEvents.y;
-      }
-
-      // Add hold notes that have been made visible (but not their parents)
-      for (noteData in currentSongChartNoteData)
-      {
-        // Is the note a hold note?
-        if (noteData == null || noteData.length <= 0) continue;
-
-        // Is the hold note rendered already?
-        if (displayedHoldNoteData.indexOf(noteData) != -1) continue;
-
-        // Is the hold note offscreen?
-        if (!ChartEditorHoldNoteSprite.wouldHoldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData, renderedHoldNotes)) continue;
-
-        // Hold note should be rendered.
-        var holdNoteFactory = function() {
-          // TODO: Print some kind of warning if `renderedHoldNotes.members` is too high?
-          return new ChartEditorHoldNoteSprite(this);
-        }
-        var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(holdNoteFactory);
-
-        var noteLengthPixels:Float = noteData.getStepLength() * GRID_SIZE;
-
-        holdNoteSprite.noteData = noteData;
-        holdNoteSprite.noteDirection = noteData.getDirection();
-
-        holdNoteSprite.setHeightDirectly(noteLengthPixels);
-
-        holdNoteSprite.updateHoldNotePosition(renderedHoldNotes);
-
-        displayedHoldNoteData.push(noteData);
-      }
-
-      // Destroy all existing selection squares.
-      for (member in renderedSelectionSquares.members)
-      {
-        // Killing the sprite is cheap because we can recycle it.
-        member.kill();
-      }
-
-      // Readd selection squares for selected notes.
-      // Recycle selection squares if possible.
-      for (noteSprite in renderedNotes.members)
-      {
-        // TODO: Handle selection of hold notes.
-        if (isNoteSelected(noteSprite.noteData))
-        {
-          var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare);
-
-          // Set the position and size (because we might be recycling one with bad values).
-          selectionSquare.x = noteSprite.x;
-          selectionSquare.y = noteSprite.y;
-          selectionSquare.width = GRID_SIZE;
-          selectionSquare.height = GRID_SIZE;
-        }
-      }
-
-      for (eventSprite in renderedEvents.members)
-      {
-        if (isEventSelected(eventSprite.eventData))
-        {
-          var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare);
-
-          // Set the position and size (because we might be recycling one with bad values).
-          selectionSquare.x = eventSprite.x;
-          selectionSquare.y = eventSprite.y;
-          selectionSquare.width = eventSprite.width;
-          selectionSquare.height = eventSprite.height;
-        }
-      }
-
-      // Sort the notes DESCENDING. This keeps the sustain behind the associated note.
-      renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
-
-      // Sort the events DESCENDING. This keeps the sustain behind the associated note.
-      renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
-    }
-
-    // Add a debug value which displays the current size of the note pool.
-    // The pool will grow as more notes need to be rendered at once.
-    // If this gets too big, something needs to be optimized somewhere! -Eric
-    FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
-    FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
-    FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
-    FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
-    FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
-  }
-
-  /**
-   * Handle aligning the health icons next to the grid.
-   */
-  function handleHealthIcons():Void
-  {
-    if (healthIconsDirty)
-    {
-      var charDataBF = CharacterDataParser.fetchCharacterData(currentSongMetadata.playData.characters.player);
-      var charDataDad = CharacterDataParser.fetchCharacterData(currentSongMetadata.playData.characters.opponent);
-      if (healthIconBF != null)
-      {
-        healthIconBF.configure(charDataBF?.healthIcon);
-        healthIconBF.size *= 0.5; // Make the icon smaller in Chart Editor.
-        healthIconBF.flipX = !healthIconBF.flipX; // BF faces the other way.
-      }
-      if (healthIconDad != null)
-      {
-        healthIconDad.configure(charDataDad?.healthIcon);
-        healthIconDad.size *= 0.5; // Make the icon smaller in Chart Editor.
-      }
-      healthIconsDirty = false;
-    }
-
-    // Right align, and visibly center, the BF health icon.
-    if (healthIconBF != null)
-    {
-      // Base X position to the right of the grid.
-      healthIconBF.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x + gridTiledSprite.width + 45 - (healthIconBF.width / 2));
-      healthIconBF.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconBF.height / 2));
-    }
-
-    // Visibly center the Dad health icon.
-    if (healthIconDad != null)
-    {
-      healthIconDad.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x - 45 - (healthIconDad.width / 2));
-      healthIconDad.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconDad.height / 2));
     }
   }
 
-  function buildSelectionSquare():FlxSprite
+  function handleToolboxes():Void
   {
-    if (selectionSquareBitmap == null)
-      throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()";
+    handleDifficultyToolbox();
+    handlePlayerPreviewToolbox();
+    handleOpponentPreviewToolbox();
+  }
 
-    FlxG.bitmapLog.add(selectionSquareBitmap, "selectionSquareBitmap");
+  function handleDifficultyToolbox():Void
+  {
+    if (difficultySelectDirty)
+    {
+      difficultySelectDirty = false;
 
-    return new FlxSprite().loadGraphic(selectionSquareBitmap);
+      // Manage the Select Difficulty tree view.
+      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      if (difficultyToolbox == null) return;
+
+      var treeView:Null<TreeView> = difficultyToolbox.findComponent('difficultyToolboxTree');
+      if (treeView == null) return;
+
+      // Clear the tree view so we can rebuild it.
+      treeView.clearNodes();
+
+      // , icon: 'haxeui-core/styles/default/haxeui_tiny.png'
+      var treeSong:TreeViewNode = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName'});
+      treeSong.expanded = true;
+
+      for (curVariation in availableVariations)
+      {
+        var variationMetadata:Null<SongMetadata> = songMetadata.get(curVariation);
+        if (variationMetadata == null) continue;
+
+        var treeVariation:TreeViewNode = treeSong.addNode(
+          {
+            id: 'stv_variation_$curVariation',
+            text: 'V: ${curVariation.toTitleCase()}'
+          });
+        treeVariation.expanded = true;
+
+        var difficultyList:Array<String> = variationMetadata.playData.difficulties;
+
+        for (difficulty in difficultyList)
+        {
+          var _treeDifficulty:TreeViewNode = treeVariation.addNode(
+            {
+              id: 'stv_difficulty_${curVariation}_$difficulty',
+              text: 'D: ${difficulty.toTitleCase()}'
+            });
+        }
+      }
+
+      treeView.onChange = onChangeTreeDifficulty;
+      refreshDifficultyTreeSelection(treeView);
+    }
+  }
+
+  function handlePlayerPreviewToolbox():Void
+  {
+    // Manage the Select Difficulty tree view.
+    var charPreviewToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
+    if (charPreviewToolbox == null) return;
+
+    // TODO: Re-enable the player preview once we figure out the performance issues.
+    var charPlayer:Null<CharacterPlayer> = null; // charPreviewToolbox.findComponent('charPlayer');
+    if (charPlayer == null) return;
+
+    currentPlayerCharacterPlayer = charPlayer;
+
+    if (playerPreviewDirty)
+    {
+      playerPreviewDirty = false;
+
+      if (currentSongMetadata.playData.characters.player != charPlayer.charId)
+      {
+        if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player;
+
+        charPlayer.loadCharacter(currentSongMetadata.playData.characters.player);
+        charPlayer.characterType = CharacterType.BF;
+        charPlayer.flip = true;
+        charPlayer.targetScale = 0.5;
+
+        charPreviewToolbox.title = 'Player Preview - ${charPlayer.charName}';
+      }
+
+      if (charPreviewToolbox != null && !charPreviewToolbox.minimized)
+      {
+        charPreviewToolbox.width = charPlayer.width + 32;
+        charPreviewToolbox.height = charPlayer.height + 64;
+      }
+    }
+  }
+
+  function handleOpponentPreviewToolbox():Void
+  {
+    // Manage the Select Difficulty tree view.
+    var charPreviewToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
+    if (charPreviewToolbox == null) return;
+
+    // TODO: Re-enable the player preview once we figure out the performance issues.
+    var charPlayer:Null<CharacterPlayer> = null; // charPreviewToolbox.findComponent('charPlayer');
+    if (charPlayer == null) return;
+
+    currentOpponentCharacterPlayer = charPlayer;
+
+    if (opponentPreviewDirty)
+    {
+      opponentPreviewDirty = false;
+
+      if (currentSongMetadata.playData.characters.opponent != charPlayer.charId)
+      {
+        if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent;
+
+        charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent);
+        charPlayer.characterType = CharacterType.DAD;
+        charPlayer.flip = false;
+        charPlayer.targetScale = 0.5;
+
+        charPreviewToolbox.title = 'Opponent Preview - ${charPlayer.charName}';
+      }
+
+      if (charPreviewToolbox != null && !charPreviewToolbox.minimized)
+      {
+        charPreviewToolbox.width = charPlayer.width + 32;
+        charPreviewToolbox.height = charPlayer.height + 64;
+      }
+    }
   }
 
   /**
@@ -3138,6 +3643,125 @@ class ChartEditorState extends HaxeUIState
     if (playbarNoteSnap != null && playbarNoteSnap.value != '1/${noteSnapQuant}') playbarNoteSnap.value = '1/${noteSnapQuant}';
   }
 
+  function handlePlayhead():Void
+  {
+    // Place notes at the playhead.
+    switch (currentLiveInputStyle)
+    {
+      case ChartEditorLiveInputStyle.WASD:
+        if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4);
+        if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5);
+        if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6);
+        if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(7);
+
+        if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(0);
+        if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(1);
+        if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(2);
+        if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(3);
+      case ChartEditorLiveInputStyle.NumberKeys:
+        // Flipped because Dad is on the left but represents data 0-3.
+        if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(4);
+        if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(5);
+        if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(6);
+        if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(7);
+
+        if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(0);
+        if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(1);
+        if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(2);
+        if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(3);
+      case ChartEditorLiveInputStyle.None:
+        // Do nothing.
+    }
+  }
+
+  function placeNoteAtPlayhead(column:Int):Void
+  {
+    var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
+    var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio;
+    var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep));
+    var playheadPosSnappedMs:Float = playheadPosStep * Conductor.stepLengthMs * noteSnapRatio;
+
+    // Look for notes within 1 step of the playhead.
+    var notesAtPos:Array<SongNoteData> = SongDataUtils.getNotesInTimeRange(currentSongChartNoteData, playheadPosSnappedMs,
+      playheadPosSnappedMs + Conductor.stepLengthMs * noteSnapRatio);
+    notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]);
+
+    if (notesAtPos.length == 0)
+    {
+      var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, selectedNoteKind);
+      performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
+    }
+    else
+    {
+      trace('Already a note there.');
+    }
+  }
+
+  /**
+   * Handles the note preview/scroll area on the right side.
+   * Notes are rendered here as small bars.
+   * This function also handles:
+   * - Moving the viewport preview box around based on its current position.
+   * - Scrolling the note preview area down if the note preview is taller than the screen,
+   *   and the viewport nears the end of the visible area.
+   */
+  function handleNotePreview():Void
+  {
+    if (notePreviewDirty && notePreview != null)
+    {
+      notePreviewDirty = false;
+
+      // TODO: Only update the notes that have changed.
+      notePreview.erase();
+      notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs));
+      notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs));
+    }
+
+    if (notePreviewViewportBoundsDirty)
+    {
+      setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+    }
+  }
+
+  /**
+   * Handle aligning the health icons next to the grid.
+   */
+  function handleHealthIcons():Void
+  {
+    if (healthIconsDirty)
+    {
+      var charDataBF = CharacterDataParser.fetchCharacterData(currentSongMetadata.playData.characters.player);
+      var charDataDad = CharacterDataParser.fetchCharacterData(currentSongMetadata.playData.characters.opponent);
+      if (healthIconBF != null)
+      {
+        healthIconBF.configure(charDataBF?.healthIcon);
+        healthIconBF.size *= 0.5; // Make the icon smaller in Chart Editor.
+        healthIconBF.flipX = !healthIconBF.flipX; // BF faces the other way.
+      }
+      if (healthIconDad != null)
+      {
+        healthIconDad.configure(charDataDad?.healthIcon);
+        healthIconDad.size *= 0.5; // Make the icon smaller in Chart Editor.
+      }
+      healthIconsDirty = false;
+    }
+
+    // Right align, and visibly center, the BF health icon.
+    if (healthIconBF != null)
+    {
+      // Base X position to the right of the grid.
+      healthIconBF.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x + gridTiledSprite.width + 45 - (healthIconBF.width / 2));
+      healthIconBF.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconBF.height / 2));
+    }
+
+    // Visibly center the Dad health icon.
+    if (healthIconDad != null)
+    {
+      healthIconDad.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x - 45 - (healthIconDad.width / 2));
+      healthIconDad.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconDad.height / 2));
+    }
+  }
+
   /**
    * Handle keybinds for File menu items.
    */
@@ -3146,19 +3770,19 @@ class ChartEditorState extends HaxeUIState
     // CTRL + N = New Chart
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.N)
     {
-      ChartEditorDialogHandler.openWelcomeDialog(this, true);
+      this.openWelcomeDialog(true);
     }
 
     // CTRL + O = Open Chart
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.O)
     {
-      ChartEditorDialogHandler.openBrowseWizard(this, true);
+      this.openBrowseWizard(true);
     }
 
     // CTRL + SHIFT + S = Save As
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.S)
     {
-      ChartEditorImportExportHandler.exportAllSongData(this, false);
+      this.exportAllSongData(false);
     }
 
     // CTRL + Q = Quit to Menu
@@ -3168,13 +3792,6 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  function quitChartEditor():Void
-  {
-    autoSave();
-    stopWelcomeMusic();
-    FlxG.switchState(new MainMenuState());
-  }
-
   /**
    * Handle keybinds for edit menu items.
    */
@@ -3289,6 +3906,383 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
+  /**
+   * Handle keybinds for the Test menu items.
+   */
+  function handleTestKeybinds():Void
+  {
+    if (!isHaxeUIDialogOpen && !isCursorOverHaxeUI && FlxG.keys.justPressed.ENTER)
+    {
+      var minimal = FlxG.keys.pressed.SHIFT;
+      this.hideAllToolboxes();
+      testSongInPlayState(minimal);
+    }
+  }
+
+  /**
+   * Handle keybinds for Help menu items.
+   */
+  function handleHelpKeybinds():Void
+  {
+    // F1 = Open Help
+    if (FlxG.keys.justPressed.F1) this.openUserGuideDialog();
+  }
+
+  function handleQuickWatch():Void
+  {
+    FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
+    FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
+
+    FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
+    FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
+    FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
+    FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
+    FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
+  }
+
+  /**
+   * PLAYTEST FUNCTIONS
+   */
+  // ====================
+
+  /**
+   * Transitions to the Play State to test the song
+   */
+  function testSongInPlayState(minimal:Bool = false):Void
+  {
+    autoSave();
+
+    var startTimestamp:Float = 0;
+    if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs;
+
+    var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
+
+    // TODO: Rework asset system so we can remove this.
+    switch (currentSongStage)
+    {
+      case 'mainStage':
+        Paths.setCurrentLevel('week1');
+      case 'spookyMansion':
+        Paths.setCurrentLevel('week2');
+      case 'phillyTrain':
+        Paths.setCurrentLevel('week3');
+      case 'limoRide':
+        Paths.setCurrentLevel('week4');
+      case 'mallXmas' | 'mallEvil':
+        Paths.setCurrentLevel('week5');
+      case 'school' | 'schoolEvil':
+        Paths.setCurrentLevel('week6');
+      case 'tankmanBattlefield':
+        Paths.setCurrentLevel('week7');
+      case 'phillyStreets' | 'phillyBlazin':
+        Paths.setCurrentLevel('weekend1');
+    }
+
+    subStateClosed.add(fixCamera);
+    subStateClosed.add(resetConductorAfterTest);
+
+    FlxTransitionableState.skipNextTransIn = false;
+    FlxTransitionableState.skipNextTransOut = false;
+
+    var targetState = new PlayState(
+      {
+        targetSong: targetSong,
+        targetDifficulty: selectedDifficulty,
+        // TODO: Add this.
+        // targetCharacter: targetCharacter,
+        practiceMode: true,
+        minimalMode: minimal,
+        startTimestamp: startTimestamp,
+        overrideMusic: true,
+      });
+
+    // Override music.
+    if (audioInstTrack != null) FlxG.sound.music = audioInstTrack;
+    if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup;
+
+    openSubState(targetState);
+  }
+
+  /**
+   * COMMAND FUNCTIONS
+   */
+  // ====================
+
+  /**
+   * Perform (or redo) a command, then add it to the undo stack.
+   *
+   * @param command The command to perform.
+   * @param purgeRedoStack If true, the redo stack will be cleared.
+   */
+  function performCommand(command:ChartEditorCommand, purgeRedoStack:Bool = true):Void
+  {
+    command.execute(this);
+    undoHistory.push(command);
+    commandHistoryDirty = true;
+    if (purgeRedoStack) redoHistory = [];
+  }
+
+  /**
+   * Undo a command, then add it to the redo stack.
+   * @param command The command to undo.
+   */
+  function undoCommand(command:ChartEditorCommand):Void
+  {
+    command.undo(this);
+    redoHistory.push(command);
+    commandHistoryDirty = true;
+  }
+
+  /**
+   * Undo the last command in the undo stack, then add it to the redo stack.
+   */
+  function undoLastCommand():Void
+  {
+    var command:Null<ChartEditorCommand> = undoHistory.pop();
+    if (command == null)
+    {
+      trace('No actions to undo.');
+      return;
+    }
+    undoCommand(command);
+  }
+
+  /**
+   * Redo the last command in the redo stack, then add it to the undo stack.
+   */
+  function redoLastCommand():Void
+  {
+    var command:Null<ChartEditorCommand> = redoHistory.pop();
+    if (command == null)
+    {
+      trace('No actions to redo.');
+      return;
+    }
+    performCommand(command, false);
+  }
+
+  /**
+   * SAVE, AUTOSAVE, QUIT FUNCTIONS
+   */
+  // ====================
+
+  /**
+   * Called after 5 minutes without saving.
+   */
+  function autoSave():Void
+  {
+    saveDataDirty = false;
+
+    // Auto-save the chart.
+
+    #if html5
+    // Auto-save to local storage.
+    #else
+    // Auto-save to temp file.
+    this.exportAllSongData(true);
+    #end
+  }
+
+  /**
+   * Called when the user presses the Quit button.
+   */
+  function quitChartEditor():Void
+  {
+    autoSave();
+    stopWelcomeMusic();
+    FlxG.switchState(new MainMenuState());
+  }
+
+  /**
+   * Called when the window is closed while we are in the chart editor.
+   * @param exitCode The exit code of the window.
+   */
+  function onWindowClose(exitCode:Int):Void
+  {
+    trace('Window exited with exit code: $exitCode');
+    trace('Should save chart? $saveDataDirty');
+
+    if (saveDataDirty)
+    {
+      this.exportAllSongData(true);
+    }
+  }
+
+  function cleanupAutoSave():Void
+  {
+    WindowUtil.windowExit.remove(onWindowClose);
+  }
+
+  /**
+   * GRAPHICS FUNCTIONS
+   */
+  // ====================
+
+  /**
+   * This is for the smaller green squares that appear over each note when you select them.
+   */
+  function buildSelectionSquare():ChartEditorSelectionSquareSprite
+  {
+    if (selectionSquareBitmap == null)
+      throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()";
+
+    // FlxG.bitmapLog.add(selectionSquareBitmap, "selectionSquareBitmap");
+    var result = new ChartEditorSelectionSquareSprite();
+    result.loadGraphic(selectionSquareBitmap);
+    return result;
+  }
+
+  /**
+   * Fix a camera issue caused when closing the PlayState used when testing.
+   */
+  function fixCamera(_:FlxSubState = null):Void
+  {
+    FlxG.cameras.reset(new FlxCamera());
+    FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2));
+    FlxG.camera.zoom = 1.0;
+
+    add(this.component);
+  }
+
+  /**
+   * AUDIO FUNCTIONS
+   */
+  // ====================
+
+  function startAudioPlayback():Void
+  {
+    if (audioInstTrack != null)
+    {
+      audioInstTrack.play(false, audioInstTrack.time);
+      if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time);
+    }
+
+    setComponentText('playbarPlay', '||');
+  }
+
+  function stopAudioPlayback():Void
+  {
+    if (audioInstTrack != null) audioInstTrack.pause();
+    if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+
+    setComponentText('playbarPlay', '>');
+  }
+
+  function toggleAudioPlayback():Void
+  {
+    if (audioInstTrack == null) return;
+
+    if (audioInstTrack.playing)
+    {
+      stopAudioPlayback();
+    }
+    else
+    {
+      startAudioPlayback();
+    }
+  }
+
+  /**
+   * Play the metronome tick sound.
+   * @param high Whether to play the full beat sound rather than the quarter beat sound.
+   */
+  function playMetronomeTick(high:Bool = false):Void
+  {
+    this.playSound(Paths.sound('chartingSounds/metronome${high ? '1' : '2'}'));
+  }
+
+  function switchToCurrentInstrumental():Void
+  {
+    // ChartEditorAudioHandler
+    this.switchToInstrumental(currentInstrumentalId, currentSongMetadata.playData.characters.player, currentSongMetadata.playData.characters.opponent);
+  }
+
+  function postLoadInstrumental():Void
+  {
+    if (audioInstTrack != null)
+    {
+      // Prevent the time from skipping back to 0 when the song ends.
+      audioInstTrack.onComplete = function() {
+        if (audioInstTrack != null) audioInstTrack.pause();
+        if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+      };
+
+      songLengthInMs = audioInstTrack.length;
+
+      if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels;
+      if (gridPlayheadScrollArea != null)
+      {
+        gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels);
+        gridPlayheadScrollArea.updateHitbox();
+      }
+
+      buildSpectrogram(audioInstTrack);
+    }
+    else
+    {
+      trace('[WARN] Instrumental track was null!');
+    }
+
+    // Pretty much everything is going to need to be reset.
+    scrollPositionInPixels = 0;
+    playheadPositionInPixels = 0;
+    notePreviewDirty = true;
+    notePreviewViewportBoundsDirty = true;
+    noteDisplayDirty = true;
+    healthIconsDirty = true;
+    moveSongToScrollPosition();
+  }
+
+  /**
+   * CHART DATA FUNCTIONS
+   */
+  // ====================
+
+  function sortChartData():Void
+  {
+    // TODO: .insertionSort()
+    currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int {
+      return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
+    });
+
+    // TODO: .insertionSort()
+    currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int {
+      return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
+    });
+  }
+
+  function isNoteSelected(note:Null<SongNoteData>):Bool
+  {
+    return note != null && currentNoteSelection.indexOf(note) != -1;
+  }
+
+  function isEventSelected(event:Null<SongEventData>):Bool
+  {
+    return event != null && currentEventSelection.indexOf(event) != -1;
+  }
+
+  function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0)
+  {
+    var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
+    if (variationMetadata == null) return;
+
+    variationMetadata.playData.difficulties.push(difficulty);
+
+    var resultChartData = songChartData.get(variation);
+    if (resultChartData == null)
+    {
+      resultChartData = new SongChartData([difficulty => scrollSpeed], [], [difficulty => []]);
+      songChartData.set(variation, resultChartData);
+    }
+    else
+    {
+      resultChartData.scrollSpeed.set(difficulty, scrollSpeed);
+      resultChartData.notes.set(difficulty, []);
+    }
+
+    difficultySelectDirty = true; // Force the Difficulty toolbox to update.
+  }
+
   function incrementDifficulty(change:Int):Void
   {
     var currentDifficultyIndex:Int = availableDifficulties.indexOf(selectedDifficulty);
@@ -3382,117 +4376,101 @@ class ChartEditorState extends HaxeUIState
         title: 'Switch Difficulty',
         body: 'Switched difficulty to ${selectedDifficulty.toTitleCase()}',
         type: NotificationType.Success,
-        expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+        expiryMs: Constants.NOTIFICATION_DISMISS_TIME
       });
     #end
   }
 
   /**
-   * Handle keybinds for the Test menu items.
+   * SCROLLING FUNCTIONS
    */
-  function handleTestKeybinds():Void
+  // ====================
+
+  /**
+   * When setting the scroll position, except when automatically scrolling during song playback,
+   * we need to update the conductor's current step time and the timestamp of the audio tracks.
+   */
+  function moveSongToScrollPosition():Void
   {
-    if (!isHaxeUIDialogOpen && !isCursorOverHaxeUI && FlxG.keys.justPressed.ENTER)
+    // Update the songPosition in the audio tracks.
+    if (audioInstTrack != null)
     {
-      var minimal = FlxG.keys.pressed.SHIFT;
-      ChartEditorToolboxHandler.hideAllToolboxes(this);
-      testSongInPlayState(minimal);
+      audioInstTrack.time = scrollPositionInMs + playheadPositionInMs;
+      // Update the songPosition in the Conductor.
+      Conductor.update(audioInstTrack.time);
+    }
+    if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs;
+
+    // We need to update the note sprites because we changed the scroll position.
+    noteDisplayDirty = true;
+  }
+
+  /**
+   * Smoothly ease the song to a new scroll position over a duration.
+   * @param targetScrollPosition The desired value for the `scrollPositionInPixels`.
+   */
+  function easeSongToScrollPosition(targetScrollPosition:Float):Void
+  {
+    if (currentScrollEase != null) cancelScrollEase(currentScrollEase);
+
+    currentScrollEase = FlxTween.tween(this, {scrollPositionInPixels: targetScrollPosition}, SCROLL_EASE_DURATION,
+      {
+        ease: FlxEase.quintInOut,
+        onUpdate: this.onScrollEaseUpdate,
+        onComplete: this.cancelScrollEase,
+        type: ONESHOT
+      });
+  }
+
+  /**
+   * Callback function executed every frame that the scroll position is being eased.
+   * @param _
+   */
+  function onScrollEaseUpdate(_:FlxTween):Void
+  {
+    moveSongToScrollPosition();
+  }
+
+  /**
+   * Callback function executed when cancelling an existing scroll position ease.
+   * Ensures that the ease is immediately cancelled and the scroll position is set to the target value.
+   */
+  function cancelScrollEase(_:FlxTween):Void
+  {
+    if (currentScrollEase != null)
+    {
+      @:privateAccess
+      var targetScrollPosition:Float = currentScrollEase._properties.scrollPositionInPixels;
+
+      currentScrollEase.cancel();
+      currentScrollEase = null;
+      this.scrollPositionInPixels = targetScrollPosition;
     }
   }
 
   /**
-   * Handle keybinds for Help menu items.
+   * Fix the current scroll position after exiting the PlayState used when testing.
    */
-  function handleHelpKeybinds():Void
+  function resetConductorAfterTest(_:FlxSubState = null):Void
   {
-    // F1 = Open Help
-    if (FlxG.keys.justPressed.F1) ChartEditorDialogHandler.openUserGuideDialog(this);
+    moveSongToScrollPosition();
   }
 
-  function handleToolboxes():Void
-  {
-    handleDifficultyToolbox();
-    handlePlayerPreviewToolbox();
-    handleOpponentPreviewToolbox();
-  }
-
-  function handleDifficultyToolbox():Void
-  {
-    if (difficultySelectDirty)
-    {
-      difficultySelectDirty = false;
-
-      // Manage the Select Difficulty tree view.
-      var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
-      if (difficultyToolbox == null) return;
-
-      var treeView:Null<TreeView> = difficultyToolbox.findComponent('difficultyToolboxTree');
-      if (treeView == null) return;
-
-      // Clear the tree view so we can rebuild it.
-      treeView.clearNodes();
-
-      // , icon: 'haxeui-core/styles/default/haxeui_tiny.png'
-      var treeSong:TreeViewNode = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName'});
-      treeSong.expanded = true;
-
-      for (curVariation in availableVariations)
-      {
-        var variationMetadata:Null<SongMetadata> = songMetadata.get(curVariation);
-        if (variationMetadata == null) continue;
-
-        var treeVariation:TreeViewNode = treeSong.addNode(
-          {
-            id: 'stv_variation_$curVariation',
-            text: 'V: ${curVariation.toTitleCase()}'
-          });
-        treeVariation.expanded = true;
-
-        var difficultyList:Array<String> = variationMetadata.playData.difficulties;
-
-        for (difficulty in difficultyList)
-        {
-          var _treeDifficulty:TreeViewNode = treeVariation.addNode(
-            {
-              id: 'stv_difficulty_${curVariation}_$difficulty',
-              text: 'D: ${difficulty.toTitleCase()}'
-            });
-        }
-      }
-
-      treeView.onChange = onChangeTreeDifficulty;
-      refreshDifficultyTreeSelection(treeView);
-    }
-  }
-
-  public function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0)
-  {
-    var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
-    if (variationMetadata == null) return;
-
-    variationMetadata.playData.difficulties.push(difficulty);
-
-    var resultChartData = songChartData.get(variation);
-    if (resultChartData == null)
-    {
-      resultChartData = new SongChartData([difficulty => scrollSpeed], [], [difficulty => []]);
-      songChartData.set(variation, resultChartData);
-    }
-    else
-    {
-      resultChartData.scrollSpeed.set(difficulty, scrollSpeed);
-      resultChartData.notes.set(difficulty, []);
-    }
-
-    difficultySelectDirty = true; // Force the Difficulty toolbox to update.
-  }
+  /**
+   * HAXEUI FUNCTIONS
+   */
+  // ====================
 
+  /**
+   * Set the currently selected item in the Difficulty tree view to the node representing the current difficulty.
+   * @param treeView The tree view to update. If `null`, the tree view will be found.
+   */
   function refreshDifficultyTreeSelection(?treeView:TreeView):Void
   {
     if (treeView == null)
     {
       // Manage the Select Difficulty tree view.
-      var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return;
 
       treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
@@ -3503,119 +4481,16 @@ class ChartEditorState extends HaxeUIState
     if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode;
   }
 
-  function handlePlayerPreviewToolbox():Void
-  {
-    // Manage the Select Difficulty tree view.
-    var charPreviewToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
-    if (charPreviewToolbox == null) return;
-
-    // TODO: Re-enable the player preview once we figure out the performance issues.
-    var charPlayer:Null<CharacterPlayer> = null; // charPreviewToolbox.findComponent('charPlayer');
-    if (charPlayer == null) return;
-
-    currentPlayerCharacterPlayer = charPlayer;
-
-    if (playerPreviewDirty)
-    {
-      playerPreviewDirty = false;
-
-      if (currentSongMetadata.playData.characters.player != charPlayer.charId)
-      {
-        if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player;
-
-        charPlayer.loadCharacter(currentSongMetadata.playData.characters.player);
-        charPlayer.characterType = CharacterType.BF;
-        charPlayer.flip = true;
-        charPlayer.targetScale = 0.5;
-
-        charPreviewToolbox.title = 'Player Preview - ${charPlayer.charName}';
-      }
-
-      if (charPreviewToolbox != null && !charPreviewToolbox.minimized)
-      {
-        charPreviewToolbox.width = charPlayer.width + 32;
-        charPreviewToolbox.height = charPlayer.height + 64;
-      }
-    }
-  }
-
-  function handleOpponentPreviewToolbox():Void
-  {
-    // Manage the Select Difficulty tree view.
-    var charPreviewToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
-    if (charPreviewToolbox == null) return;
-
-    // TODO: Re-enable the player preview once we figure out the performance issues.
-    var charPlayer:Null<CharacterPlayer> = null; // charPreviewToolbox.findComponent('charPlayer');
-    if (charPlayer == null) return;
-
-    currentOpponentCharacterPlayer = charPlayer;
-
-    if (opponentPreviewDirty)
-    {
-      opponentPreviewDirty = false;
-
-      if (currentSongMetadata.playData.characters.opponent != charPlayer.charId)
-      {
-        if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent;
-
-        charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent);
-        charPlayer.characterType = CharacterType.DAD;
-        charPlayer.flip = false;
-        charPlayer.targetScale = 0.5;
-
-        charPreviewToolbox.title = 'Opponent Preview - ${charPlayer.charName}';
-      }
-
-      if (charPreviewToolbox != null && !charPreviewToolbox.minimized)
-      {
-        charPreviewToolbox.width = charPlayer.width + 32;
-        charPreviewToolbox.height = charPlayer.height + 64;
-      }
-    }
-  }
-
-  public override function dispatchEvent(event:ScriptEvent):Void
-  {
-    super.dispatchEvent(event);
-
-    // We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it.
-    if (currentPlayerCharacterPlayer != null)
-    {
-      switch (event.type)
-      {
-        case ScriptEvent.UPDATE:
-          currentPlayerCharacterPlayer.onUpdate(cast event);
-        case ScriptEvent.SONG_BEAT_HIT:
-          currentPlayerCharacterPlayer.onBeatHit(cast event);
-        case ScriptEvent.SONG_STEP_HIT:
-          currentPlayerCharacterPlayer.onStepHit(cast event);
-        case ScriptEvent.NOTE_HIT:
-          currentPlayerCharacterPlayer.onNoteHit(cast event);
-      }
-    }
-
-    if (currentOpponentCharacterPlayer != null)
-    {
-      switch (event.type)
-      {
-        case ScriptEvent.UPDATE:
-          currentOpponentCharacterPlayer.onUpdate(cast event);
-        case ScriptEvent.SONG_BEAT_HIT:
-          currentOpponentCharacterPlayer.onBeatHit(cast event);
-        case ScriptEvent.SONG_STEP_HIT:
-          currentOpponentCharacterPlayer.onStepHit(cast event);
-        case ScriptEvent.NOTE_HIT:
-          currentOpponentCharacterPlayer.onNoteHit(cast event);
-      }
-    }
-  }
-
+  /**
+   * Retrieve the node representing the current difficulty in the Difficulty tree view.
+   * @param treeView The tree view to search. If `null`, the tree view will be found.
+   * @return The node representing the current difficulty, or `null` if not found.
+   */
   function getCurrentTreeDifficultyNode(?treeView:TreeView = null):Null<TreeViewNode>
   {
     if (treeView == null)
     {
-      var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return null;
 
       treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
@@ -3678,7 +4553,7 @@ class ChartEditorState extends HaxeUIState
    */
   function refreshMetadataToolbox():Void
   {
-    var toolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+    var toolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
     if (toolbox == null) return;
 
     var inputSongName:Null<TextField> = toolbox.findComponent('inputSongName', TextField);
@@ -3749,605 +4624,104 @@ class ChartEditorState extends HaxeUIState
   }
 
   /**
-   * Handle the player preview/gameplay test area on the left side.
+   * STATIC FUNCTIONS
    */
-  function handlePlayerDisplay():Void {}
+  // ====================
 
   /**
-   * Handles the note preview/scroll area on the right side.
-   * Notes are rendered here as small bars.
-   * This function also handles:
-   * - Moving the viewport preview box around based on its current position.
-   * - Scrolling the note preview area down if the note preview is taller than the screen,
-   *   and the viewport nears the end of the visible area.
+   * Dismiss any existing HaxeUI notifications, if there are any.
    */
-  function handleNotePreview():Void
-  {
-    if (notePreviewDirty && notePreview != null)
-    {
-      notePreviewDirty = false;
-
-      // TODO: Only update the notes that have changed.
-      notePreview.erase();
-      notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs));
-      notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs));
-    }
-
-    if (notePreviewViewportBoundsDirty)
-    {
-      setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
-    }
-  }
-
-  /**
-   * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status.
-   * Does not handle onClick ACTIONS of the menubar.
-   */
-  function handleMenubar():Void
-  {
-    if (commandHistoryDirty)
-    {
-      commandHistoryDirty = false;
-
-      // Update the Undo and Redo buttons.
-      var undoButton:Null<MenuItem> = findComponent('menubarItemUndo', MenuItem);
-
-      if (undoButton != null)
-      {
-        if (undoHistory.length == 0)
-        {
-          // Disable the Undo button.
-          undoButton.disabled = true;
-          undoButton.text = 'Undo';
-        }
-        else
-        {
-          // Change the label to the last command.
-          undoButton.disabled = false;
-          undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}';
-        }
-      }
-      else
-      {
-        trace('undoButton is null');
-      }
-
-      var redoButton:Null<MenuItem> = findComponent('menubarItemRedo', MenuItem);
-
-      if (redoButton != null)
-      {
-        if (redoHistory.length == 0)
-        {
-          // Disable the Redo button.
-          redoButton.disabled = true;
-          redoButton.text = 'Redo';
-        }
-        else
-        {
-          // Change the label to the last command.
-          redoButton.disabled = false;
-          redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}';
-        }
-      }
-      else
-      {
-        trace('redoButton is null');
-      }
-    }
-  }
-
-  /**
-   * Handle syncronizing the conductor with the music playback.
-   */
-  function handleMusicPlayback():Void
-  {
-    if (audioInstTrack != null && audioInstTrack.playing)
-    {
-      if (FlxG.mouse.pressedMiddle)
-      {
-        // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat!
-
-        var oldStepTime:Float = Conductor.currentStepTime;
-        var oldSongPosition:Float = Conductor.songPosition;
-        Conductor.update(audioInstTrack.time);
-        handleHitsounds(oldSongPosition, Conductor.songPosition);
-        // Resync vocals.
-        if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
-        {
-          audioVocalTrackGroup.time = audioInstTrack.time;
-        }
-        var diffStepTime:Float = Conductor.currentStepTime - oldStepTime;
-
-        // Move the playhead.
-        playheadPositionInPixels += diffStepTime * GRID_SIZE;
-
-        // We don't move the song to scroll position, or update the note sprites.
-      }
-      else
-      {
-        // Else, move the entire view.
-        var oldSongPosition:Float = Conductor.songPosition;
-        Conductor.update(audioInstTrack.time);
-        handleHitsounds(oldSongPosition, Conductor.songPosition);
-        // Resync vocals.
-        if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
-        {
-          audioVocalTrackGroup.time = audioInstTrack.time;
-        }
-
-        // We need time in fractional steps here to allow the song to actually play.
-        // Also account for a potentially offset playhead.
-        scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels;
-
-        // DO NOT move song to scroll position here specifically.
-
-        // We need to update the note sprites.
-        noteDisplayDirty = true;
-
-        // Update the note preview viewport box.
-        setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
-      }
-    }
-
-    if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen)
-    {
-      toggleAudioPlayback();
-    }
-  }
-
-  /**
-   * Handle the playback of hitsounds.
-   */
-  function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void
-  {
-    if (!hitsoundsEnabled) return;
-
-    // Assume notes are sorted by time.
-    for (noteData in currentSongChartNoteData)
-    {
-      // Check for notes between the old and new song positions.
-
-      if (noteData.time < oldSongPosition) // Note is in the past.
-        continue;
-
-      if (noteData.time > newSongPosition) // Note is in the future.
-        return; // Assume all notes are also in the future.
-
-      // Note was just hit.
-
-      // Character preview.
-
-      // NoteScriptEvent takes a sprite, ehe. Need to rework that.
-      var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault());
-      tempNote.noteData = noteData;
-      tempNote.scrollFactor.set(0, 0);
-      var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true);
-      dispatchEvent(event);
-
-      // Calling event.cancelEvent() skips all the other logic! Neat!
-      if (event.eventCanceled) continue;
-
-      // Hitsounds.
-      switch (noteData.getStrumlineIndex())
-      {
-        case 0: // Player
-          if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/hitNotePlayer'));
-        case 1: // Opponent
-          if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/hitNoteOpponent'));
-      }
-    }
-  }
-
-  function startAudioPlayback():Void
-  {
-    if (audioInstTrack != null)
-    {
-      audioInstTrack.play(false, audioInstTrack.time);
-      if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time);
-    }
-
-    setComponentText('playbarPlay', '||');
-  }
-
-  function stopAudioPlayback():Void
-  {
-    if (audioInstTrack != null) audioInstTrack.pause();
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
-
-    setComponentText('playbarPlay', '>');
-  }
-
-  function toggleAudioPlayback():Void
-  {
-    if (audioInstTrack == null) return;
-
-    if (audioInstTrack.playing)
-    {
-      stopAudioPlayback();
-    }
-    else
-    {
-      startAudioPlayback();
-    }
-  }
-
-  function handlePlayhead():Void
-  {
-    // Place notes at the playhead.
-    switch (currentLiveInputStyle)
-    {
-      case LiveInputStyle.WASD:
-        if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4);
-        if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5);
-        if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6);
-        if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(7);
-
-        if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(0);
-        if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(1);
-        if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(2);
-        if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(3);
-      case LiveInputStyle.NumberKeys:
-        // Flipped because Dad is on the left but represents data 0-3.
-        if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(4);
-        if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(5);
-        if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(6);
-        if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(7);
-
-        if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(0);
-        if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(1);
-        if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(2);
-        if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(3);
-      case LiveInputStyle.None:
-        // Do nothing.
-    }
-  }
-
-  function placeNoteAtPlayhead(column:Int):Void
-  {
-    var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
-    var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio;
-    var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep));
-    var playheadPosSnappedMs:Float = playheadPosStep * Conductor.stepLengthMs * noteSnapRatio;
-
-    // Look for notes within 1 step of the playhead.
-    var notesAtPos:Array<SongNoteData> = SongDataUtils.getNotesInTimeRange(currentSongChartNoteData, playheadPosSnappedMs,
-      playheadPosSnappedMs + Conductor.stepLengthMs * noteSnapRatio);
-    notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]);
-
-    if (notesAtPos.length == 0)
-    {
-      var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, selectedNoteKind);
-      performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
-    }
-    else
-    {
-      trace('Already a note there.');
-    }
-  }
-
-  function set_scrollPositionInPixels(value:Float):Float
-  {
-    if (value < 0)
-    {
-      // If we're scrolling up, and we hit the top,
-      // but the playhead is in the middle, move the playhead up.
-      if (playheadPositionInPixels > 0)
-      {
-        var amount:Float = scrollPositionInPixels - value;
-        playheadPositionInPixels -= amount;
-      }
-
-      value = 0;
-    }
-
-    if (value > songLengthInPixels) value = songLengthInPixels;
-
-    if (value == scrollPositionInPixels) return value;
-
-    // Difference in pixels.
-    var diff:Float = value - scrollPositionInPixels;
-
-    this.scrollPositionInPixels = value;
-
-    // Move the grid sprite to the correct position.
-    if (gridTiledSprite != null && gridPlayheadScrollArea != null)
-    {
-      if (isViewDownscroll)
-      {
-        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-        gridPlayheadScrollArea.y = gridTiledSprite.y;
-      }
-      else
-      {
-        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-        gridPlayheadScrollArea.y = gridTiledSprite.y;
-      }
-    }
-
-    // Move the rendered notes to the correct position.
-    renderedNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
-    renderedHoldNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
-    renderedEvents.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
-    renderedSelectionSquares.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
-    // Offset the selection box start position, if we are dragging.
-    if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff;
-    // Update the note preview viewport box.
-    setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
-    return this.scrollPositionInPixels;
-  }
-
-  /**
-   * Transitions to the Play State to test the song
-   */
-  public function testSongInPlayState(minimal:Bool = false):Void
-  {
-    autoSave();
-
-    var startTimestamp:Float = 0;
-    if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs;
-
-    var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
-
-    // TODO: Rework asset system so we can remove this.
-    switch (currentSongStage)
-    {
-      case 'mainStage':
-        Paths.setCurrentLevel('week1');
-      case 'spookyMansion':
-        Paths.setCurrentLevel('week2');
-      case 'phillyTrain':
-        Paths.setCurrentLevel('week3');
-      case 'limoRide':
-        Paths.setCurrentLevel('week4');
-      case 'mallXmas' | 'mallEvil':
-        Paths.setCurrentLevel('week5');
-      case 'school' | 'schoolEvil':
-        Paths.setCurrentLevel('week6');
-      case 'tankmanBattlefield':
-        Paths.setCurrentLevel('week7');
-      case 'phillyStreets' | 'phillyBlazin':
-        Paths.setCurrentLevel('weekend1');
-    }
-
-    subStateClosed.add(fixCamera);
-    subStateClosed.add(resetConductorAfterTest);
-
-    FlxTransitionableState.skipNextTransIn = false;
-    FlxTransitionableState.skipNextTransOut = false;
-
-    var targetState = new PlayState(
-      {
-        targetSong: targetSong,
-        targetDifficulty: selectedDifficulty,
-        // TODO: Add this.
-        // targetCharacter: targetCharacter,
-        practiceMode: true,
-        minimalMode: minimal,
-        startTimestamp: startTimestamp,
-        overrideMusic: true,
-      });
-
-    // Override music.
-    if (audioInstTrack != null) FlxG.sound.music = audioInstTrack;
-    if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup;
-
-    openSubState(targetState);
-  }
-
-  function fixCamera(_:FlxSubState = null):Void
-  {
-    FlxG.cameras.reset(new FlxCamera());
-    FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2));
-    FlxG.camera.zoom = 1.0;
-
-    add(this.component);
-  }
-
-  function resetConductorAfterTest(_:FlxSubState = null):Void
-  {
-    moveSongToScrollPosition();
-  }
-
-  public function postLoadInstrumental():Void
-  {
-    if (audioInstTrack != null)
-    {
-      // Prevent the time from skipping back to 0 when the song ends.
-      audioInstTrack.onComplete = function() {
-        if (audioInstTrack != null) audioInstTrack.pause();
-        if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
-      };
-
-      songLengthInMs = audioInstTrack.length;
-
-      if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels;
-      if (gridPlayheadScrollArea != null)
-      {
-        gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels);
-        gridPlayheadScrollArea.updateHitbox();
-      }
-
-      buildSpectrogram(audioInstTrack);
-    }
-    else
-    {
-      trace('[WARN] Instrumental track was null!');
-    }
-
-    // Pretty much everything is going to need to be reset.
-    scrollPositionInPixels = 0;
-    playheadPositionInPixels = 0;
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-    noteDisplayDirty = true;
-    healthIconsDirty = true;
-    moveSongToScrollPosition();
-  }
-
-  /**
-   * Clear the voices group.
-   */
-  public function clearVocals():Void
-  {
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear();
-  }
-
-  /**
-   * When setting the scroll position, except when automatically scrolling during song playback,
-   * we need to update the conductor's current step time and the timestamp of the audio tracks.
-   */
-  function moveSongToScrollPosition():Void
-  {
-    // Update the songPosition in the audio tracks.
-    if (audioInstTrack != null)
-    {
-      audioInstTrack.time = scrollPositionInMs + playheadPositionInMs;
-      // Update the songPosition in the Conductor.
-      Conductor.update(audioInstTrack.time);
-    }
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs;
-
-    // We need to update the note sprites because we changed the scroll position.
-    noteDisplayDirty = true;
-  }
-
-  function easeSongToScrollPosition(targetScrollPosition:Float):Void
-  {
-    if (currentScrollEase != null) cancelScrollEase(currentScrollEase);
-
-    currentScrollEase = FlxTween.tween(this, {scrollPositionInPixels: targetScrollPosition}, SCROLL_EASE_DURATION,
-      {
-        ease: FlxEase.quintInOut,
-        onUpdate: this.onScrollEaseUpdate,
-        onComplete: this.cancelScrollEase,
-        type: ONESHOT
-      });
-  }
-
-  function onScrollEaseUpdate(_:FlxTween):Void
-  {
-    moveSongToScrollPosition();
-  }
-
-  function cancelScrollEase(_:FlxTween):Void
-  {
-    if (currentScrollEase != null)
-    {
-      @:privateAccess
-      var targetScrollPosition:Float = currentScrollEase._properties.scrollPositionInPixels;
-
-      currentScrollEase.cancel();
-      currentScrollEase = null;
-      this.scrollPositionInPixels = targetScrollPosition;
-    }
-  }
-
-  /**
-   * Perform (or redo) a command, then add it to the undo stack.
-   *
-   * @param command The command to perform.
-   * @param purgeRedoStack If true, the redo stack will be cleared.
-   */
-  function performCommand(command:ChartEditorCommand, purgeRedoStack:Bool = true):Void
-  {
-    command.execute(this);
-    undoHistory.push(command);
-    commandHistoryDirty = true;
-    if (purgeRedoStack) redoHistory = [];
-  }
-
-  /**
-   * Undo a command, then add it to the redo stack.
-   * @param command The command to undo.
-   */
-  function undoCommand(command:ChartEditorCommand):Void
-  {
-    command.undo(this);
-    redoHistory.push(command);
-    commandHistoryDirty = true;
-  }
-
-  /**
-   * Undo the last command in the undo stack, then add it to the redo stack.
-   */
-  function undoLastCommand():Void
-  {
-    var command:Null<ChartEditorCommand> = undoHistory.pop();
-    if (command == null)
-    {
-      trace('No actions to undo.');
-      return;
-    }
-    undoCommand(command);
-  }
-
-  /**
-   * Redo the last command in the redo stack, then add it to the undo stack.
-   */
-  function redoLastCommand():Void
-  {
-    var command:Null<ChartEditorCommand> = redoHistory.pop();
-    if (command == null)
-    {
-      trace('No actions to redo.');
-      return;
-    }
-    performCommand(command, false);
-  }
-
-  function sortChartData():Void
-  {
-    // TODO: .insertionSort()
-    currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int {
-      return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
-    });
-
-    // TODO: .insertionSort()
-    currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int {
-      return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
-    });
-  }
-
-  function playMetronomeTick(high:Bool = false):Void
-  {
-    ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/metronome${high ? '1' : '2'}'));
-  }
-
-  function isNoteSelected(note:Null<SongNoteData>):Bool
-  {
-    return note != null && currentNoteSelection.indexOf(note) != -1;
-  }
-
-  function isEventSelected(event:Null<SongEventData>):Bool
-  {
-    return event != null && currentEventSelection.indexOf(event) != -1;
-  }
-
-  override function destroy():Void
-  {
-    super.destroy();
-
-    cleanupAutoSave();
-
-    // Hide the mouse cursor on other states.
-    Cursor.hide();
-
-    @:privateAccess
-    ChartEditorNoteSprite.noteFrameCollection = null;
-  }
-
-  /**
-   * Dismiss any existing notifications, if there are any.
-   */
-  function dismissNotifications():Void
+  public static function dismissNotifications():Void
   {
     NotificationManager.instance.clearNotifications();
   }
+
+  /**
+   * Convert a note data value into a chart editor grid column number.
+   */
+  public static function noteDataToGridColumn(input:Int):Int
+  {
+    if (input < 0) input = 0;
+    if (input >= (ChartEditorState.STRUMLINE_SIZE * 2 + 1))
+    {
+      // Don't invert the Event column.
+      input = (ChartEditorState.STRUMLINE_SIZE * 2 + 1);
+    }
+    else
+    {
+      // Invert player and opponent columns.
+      if (input >= ChartEditorState.STRUMLINE_SIZE)
+      {
+        input -= ChartEditorState.STRUMLINE_SIZE;
+      }
+      else
+      {
+        input += ChartEditorState.STRUMLINE_SIZE;
+      }
+    }
+    return input;
+  }
+
+  /**
+   * Convert a chart editor grid column number into a note data value.
+   */
+  public static function gridColumnToNoteData(input:Int):Int
+  {
+    if (input < 0) input = 0;
+    if (input >= (ChartEditorState.STRUMLINE_SIZE * 2 + 1))
+    {
+      // Don't invert the Event column.
+      input = (ChartEditorState.STRUMLINE_SIZE * 2 + 1);
+    }
+    else
+    {
+      // Invert player and opponent columns.
+      if (input >= ChartEditorState.STRUMLINE_SIZE)
+      {
+        input -= ChartEditorState.STRUMLINE_SIZE;
+      }
+      else
+      {
+        input += ChartEditorState.STRUMLINE_SIZE;
+      }
+    }
+    return input;
+  }
 }
 
-enum LiveInputStyle
+/**
+ * Available input modes for the chart editor state.
+ */
+enum ChartEditorLiveInputStyle
 {
+  /**
+   * No hotkeys to place notes at the playbar.
+   */
   None;
+
+  /**
+   * 1/2/3/4 to place notes on opponent's side, 5/6/7/8 to place notes on player's side.
+   */
   NumberKeys;
+
+  /**
+   * WASD to place notes on opponent's side, arrow keys to place notes on player's side.
+   */
   WASD;
 }
+
+/**
+ * Available themes for the chart editor state.
+ */
+enum ChartEditorTheme
+{
+  /**
+   * The default theme for the chart editor.
+   */
+  Light;
+
+  /**
+   * A theme which introduces darker colors.
+   */
+  Dark;
+}
diff --git a/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx
new file mode 100644
index 000000000..9bf8ec3db
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx
@@ -0,0 +1,67 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Adds the given events to the current chart in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class AddEventsCommand implements ChartEditorCommand
+{
+  var events:Array<SongEventData>;
+  var appendToSelection:Bool;
+
+  public function new(events:Array<SongEventData>, appendToSelection:Bool = false)
+  {
+    this.events = events;
+    this.appendToSelection = appendToSelection;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    for (event in events)
+    {
+      state.currentSongChartEventData.push(event);
+    }
+
+    if (appendToSelection)
+    {
+      state.currentEventSelection = state.currentEventSelection.concat(events);
+    }
+    else
+    {
+      state.currentNoteSelection = [];
+      state.currentEventSelection = events;
+    }
+
+    state.playSound(Paths.sound('chartingSounds/noteLay'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+
+    state.currentNoteSelection = [];
+    state.currentEventSelection = [];
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    var len:Int = events.length;
+    return 'Add $len Events';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx
new file mode 100644
index 000000000..ce4e73ea2
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx
@@ -0,0 +1,72 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Adds the given notes to the current chart in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class AddNotesCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var appendToSelection:Bool;
+
+  public function new(notes:Array<SongNoteData>, appendToSelection:Bool = false)
+  {
+    this.notes = notes;
+    this.appendToSelection = appendToSelection;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    for (note in notes)
+    {
+      state.currentSongChartNoteData.push(note);
+    }
+
+    if (appendToSelection)
+    {
+      state.currentNoteSelection = state.currentNoteSelection.concat(notes);
+    }
+    else
+    {
+      state.currentNoteSelection = notes;
+      state.currentEventSelection = [];
+    }
+
+    state.playSound(Paths.sound('chartingSounds/noteLay'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+    state.currentNoteSelection = [];
+    state.currentEventSelection = [];
+    state.playSound(Paths.sound('chartingSounds/undo'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    if (notes.length == 1)
+    {
+      var dir:String = notes[0].getDirectionName();
+      return 'Add $dir Note';
+    }
+
+    return 'Add ${notes.length} Notes';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx
new file mode 100644
index 000000000..cfa169908
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx
@@ -0,0 +1,30 @@
+package funkin.ui.debug.charting.commands;
+
+/**
+ * Actions in the chart editor are backed by the Command pattern
+ * (see Bob Nystrom's book "Game Programming Patterns" for more info)
+ *
+ * To make a functionality compatible with the undo/redo history, create a new class
+ * that implements ChartEditorCommand, then call `ChartEditorState.performCommand(new Command())`
+ */
+interface ChartEditorCommand
+{
+  /**
+   * Calling this function should perform the action that this command represents.
+   * @param state The ChartEditorState to perform the action on.
+   */
+  public function execute(state:ChartEditorState):Void;
+
+  /**
+   * Calling this function should perform the inverse of the action that this command represents,
+   * effectively undoing the action. Assume that the original action was the last action performed.
+   * @param state The ChartEditorState to undo the action on.
+   */
+  public function undo(state:ChartEditorState):Void;
+
+  /**
+   * Get a short description of the action (for the UI).
+   * For example, return `Add Left Note` to display `Undo Add Left Note` in the menu.
+   */
+  public function toString():String;
+}
diff --git a/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx
new file mode 100644
index 000000000..d0301b1ec
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx
@@ -0,0 +1,68 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Command that copies a given set of notes and song events to the clipboard,
+ * and then deletes them from the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class CutItemsCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var events:Array<SongEventData>;
+
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+  {
+    this.notes = notes;
+    this.events = events;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    // Copy the notes.
+    SongDataUtils.writeItemsToClipboard(
+      {
+        notes: SongDataUtils.buildNoteClipboard(notes),
+        events: SongDataUtils.buildEventClipboard(events)
+      });
+
+    // Delete the notes.
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+    state.currentNoteSelection = [];
+    state.currentEventSelection = [];
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
+    state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
+
+    state.currentNoteSelection = notes;
+    state.currentEventSelection = events;
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    var len:Int = notes.length + events.length;
+
+    if (notes.length == 0) return 'Cut $len Events to Clipboard';
+    else if (events.length == 0) return 'Cut $len Notes to Clipboard';
+    else
+      return 'Cut $len Items to Clipboard';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx
new file mode 100644
index 000000000..cbde0ab3d
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx
@@ -0,0 +1,42 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+
+/**
+ * Command that deselects all selected notes and events in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class DeselectAllItemsCommand implements ChartEditorCommand
+{
+  var previousNoteSelection:Array<SongNoteData>;
+  var previousEventSelection:Array<SongEventData>;
+
+  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
+  {
+    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = [];
+    state.currentEventSelection = [];
+
+    state.noteDisplayDirty = true;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = previousNoteSelection;
+    state.currentEventSelection = previousEventSelection;
+
+    state.noteDisplayDirty = true;
+  }
+
+  public function toString():String
+  {
+    return 'Deselect All Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx
new file mode 100644
index 000000000..d679b5363
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx
@@ -0,0 +1,60 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Command to deselect a specific set of notes and events in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class DeselectItemsCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var events:Array<SongEventData>;
+
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+  {
+    this.notes = notes;
+    this.events = events;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
+    state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
+
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    for (note in this.notes)
+    {
+      state.currentNoteSelection.push(note);
+    }
+
+    for (event in this.events)
+    {
+      state.currentEventSelection.push(event);
+    }
+
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+  }
+
+  public function toString():String
+  {
+    var noteCount = notes.length + events.length;
+
+    if (noteCount == 1)
+    {
+      var dir:String = notes[0].getDirectionName();
+      return 'Deselect $dir Items';
+    }
+
+    return 'Deselect ${noteCount} Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
new file mode 100644
index 000000000..47da0dde5
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
@@ -0,0 +1,52 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+
+/**
+ * Command that modifies the length of a hold note in the chart editor.
+ * If it is not a hold note, it will become one, and if it is already a hold note, its length will change.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ExtendNoteLengthCommand implements ChartEditorCommand
+{
+  var note:SongNoteData;
+  var oldLength:Float;
+  var newLength:Float;
+
+  public function new(note:SongNoteData, newLength:Float)
+  {
+    this.note = note;
+    this.oldLength = note.length;
+    this.newLength = newLength;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    note.length = newLength;
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.playSound(Paths.sound('chartingSounds/undo'));
+
+    note.length = oldLength;
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    return 'Extend Note Length';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx
new file mode 100644
index 000000000..da8ec7fbc
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx
@@ -0,0 +1,59 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Command that flips a given array of notes from the player's side of the chart editor to the opponent's side, or vice versa.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class FlipNotesCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData> = [];
+  var flippedNotes:Array<SongNoteData> = [];
+
+  public function new(notes:Array<SongNoteData>)
+  {
+    this.notes = notes;
+    this.flippedNotes = SongDataUtils.flipNotes(notes);
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    // Delete the notes.
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+
+    // Add the flipped notes.
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes);
+
+    state.currentNoteSelection = flippedNotes;
+    state.currentEventSelection = [];
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, flippedNotes);
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
+
+    state.currentNoteSelection = notes;
+    state.currentEventSelection = [];
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    var len:Int = notes.length;
+    return 'Flip $len Notes';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx
new file mode 100644
index 000000000..6e37bcc03
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx
@@ -0,0 +1,43 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Command to deselect all items that are currently selected in the chart editor,
+ * then select all the items that were previously unselected.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class InvertSelectedItemsCommand implements ChartEditorCommand
+{
+  var previousNoteSelection:Array<SongNoteData>;
+  var previousEventSelection:Array<SongEventData>;
+
+  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
+  {
+    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection);
+    state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection);
+    state.noteDisplayDirty = true;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = previousNoteSelection;
+    state.currentEventSelection = previousEventSelection;
+
+    state.noteDisplayDirty = true;
+  }
+
+  public function toString():String
+  {
+    return 'Invert Selected Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx
new file mode 100644
index 000000000..09b81c4d3
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx
@@ -0,0 +1,72 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Move the given events by the given offset and shift them by the given number of columns in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class MoveEventsCommand implements ChartEditorCommand
+{
+  var events:Array<SongEventData>;
+  var movedEvents:Array<SongEventData>;
+  var offset:Float;
+
+  public function new(notes:Array<SongEventData>, offset:Float)
+  {
+    // Clone the notes to prevent editing from affecting the history.
+    this.events = [for (event in events) event.clone()];
+    this.offset = offset;
+    this.movedEvents = [];
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+
+    movedEvents = [];
+
+    for (event in events)
+    {
+      // Clone the notes to prevent editing from affecting the history.
+      var resultEvent = event.clone();
+      resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+
+      movedEvents.push(resultEvent);
+    }
+
+    state.currentSongChartEventData = state.currentSongChartEventData.concat(movedEvents);
+    state.currentEventSelection = movedEvents;
+
+    state.playSound(Paths.sound('chartingSounds/noteLay'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, movedEvents);
+    state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
+
+    state.currentEventSelection = events;
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    var len:Int = events.length;
+    return 'Move $len Events';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx
new file mode 100644
index 000000000..2eedbbf03
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx
@@ -0,0 +1,96 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Move the given notes by the given offset and shift them by the given number of columns in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class MoveItemsCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var movedNotes:Array<SongNoteData>;
+  var events:Array<SongEventData>;
+  var movedEvents:Array<SongEventData>;
+  var offset:Float;
+  var columns:Int;
+
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, offset:Float, columns:Int)
+  {
+    // Clone the notes to prevent editing from affecting the history.
+    this.notes = [for (note in notes) note.clone()];
+    this.events = [for (event in events) event.clone()];
+    this.offset = offset;
+    this.columns = columns;
+    this.movedNotes = [];
+    this.movedEvents = [];
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+
+    movedNotes = [];
+    movedEvents = [];
+
+    for (note in notes)
+    {
+      // Clone the notes to prevent editing from affecting the history.
+      var resultNote = note.clone();
+      resultNote.time = (resultNote.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+      resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0,
+        ChartEditorState.STRUMLINE_SIZE * 2 - 1));
+
+      movedNotes.push(resultNote);
+    }
+
+    for (event in events)
+    {
+      // Clone the notes to prevent editing from affecting the history.
+      var resultEvent = event.clone();
+      resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+
+      movedEvents.push(resultEvent);
+    }
+
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(movedNotes);
+    state.currentSongChartEventData = state.currentSongChartEventData.concat(movedEvents);
+    state.currentNoteSelection = movedNotes;
+    state.currentEventSelection = movedEvents;
+
+    state.playSound(Paths.sound('chartingSounds/noteLay'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, movedNotes);
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, movedEvents);
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
+    state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
+
+    state.currentNoteSelection = notes;
+    state.currentEventSelection = events;
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    var len:Int = notes.length + events.length;
+    return 'Move $len Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx
new file mode 100644
index 000000000..8bce747a1
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx
@@ -0,0 +1,75 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Move the given notes by the given offset and shift them by the given number of columns in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class MoveNotesCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var movedNotes:Array<SongNoteData>;
+  var offset:Float;
+  var columns:Int;
+
+  public function new(notes:Array<SongNoteData>, offset:Float, columns:Int)
+  {
+    // Clone the notes to prevent editing from affecting the history.
+    this.notes = [for (note in notes) note.clone()];
+    this.offset = offset;
+    this.columns = columns;
+    this.movedNotes = [];
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+
+    movedNotes = [];
+
+    for (note in notes)
+    {
+      // Clone the notes to prevent editing from affecting the history.
+      var resultNote = note.clone();
+      resultNote.time = (resultNote.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+      resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0,
+        ChartEditorState.STRUMLINE_SIZE * 2 - 1));
+
+      movedNotes.push(resultNote);
+    }
+
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(movedNotes);
+    state.currentNoteSelection = movedNotes;
+
+    state.playSound(Paths.sound('chartingSounds/noteLay'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, movedNotes);
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
+
+    state.currentNoteSelection = notes;
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    var len:Int = notes.length;
+    return 'Move $len Notes';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
new file mode 100644
index 000000000..12115ba8a
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
@@ -0,0 +1,99 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+import funkin.data.song.SongDataUtils.SongClipboardItems;
+import haxe.ui.notifications.NotificationManager;
+import haxe.ui.notifications.NotificationType;
+
+/**
+ * A command which inserts the contents of the clipboard into the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class PasteItemsCommand implements ChartEditorCommand
+{
+  var targetTimestamp:Float;
+  // Notes we added with this command, for undo.
+  var addedNotes:Array<SongNoteData> = [];
+  var addedEvents:Array<SongEventData> = [];
+
+  public function new(targetTimestamp:Float)
+  {
+    this.targetTimestamp = targetTimestamp;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
+
+    if (currentClipboard.valid != true)
+    {
+      #if !mac
+      NotificationManager.instance.addNotification(
+        {
+          title: 'Failed to Paste',
+          body: 'Could not parse clipboard contents.',
+          type: NotificationType.Error,
+          expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+        });
+      #end
+      return;
+    }
+
+    trace(currentClipboard.notes);
+
+    addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp));
+    addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp));
+
+    state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes);
+    state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents);
+    state.currentNoteSelection = addedNotes.copy();
+    state.currentEventSelection = addedEvents.copy();
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+
+    #if !mac
+    NotificationManager.instance.addNotification(
+      {
+        title: 'Paste Successful',
+        body: 'Successfully pasted clipboard contents.',
+        type: NotificationType.Success,
+        expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+      });
+    #end
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.playSound(Paths.sound('chartingSounds/undo'));
+
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes);
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents);
+    state.currentNoteSelection = [];
+    state.currentEventSelection = [];
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
+
+    var len:Int = currentClipboard.notes.length + currentClipboard.events.length;
+
+    if (currentClipboard.notes.length == 0) return 'Paste $len Events';
+    else if (currentClipboard.events.length == 0) return 'Paste $len Notes';
+    else
+      return 'Paste $len Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
new file mode 100644
index 000000000..7e620c210
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
@@ -0,0 +1,60 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Deletes the given events from the current chart in the chart editor.
+ * Use only when ONLY events are being deleted.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class RemoveEventsCommand implements ChartEditorCommand
+{
+  var events:Array<SongEventData>;
+
+  public function new(events:Array<SongEventData>)
+  {
+    this.events = events;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+    state.currentEventSelection = [];
+
+    state.playSound(Paths.sound('chartingSounds/noteErase'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    for (event in events)
+    {
+      state.currentSongChartEventData.push(event);
+    }
+    state.currentEventSelection = events;
+    state.playSound(Paths.sound('chartingSounds/undo'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    if (events.length == 1 && events[0] != null)
+    {
+      return 'Remove Event';
+    }
+
+    return 'Remove ${events.length} Events';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
new file mode 100644
index 000000000..77184209e
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
@@ -0,0 +1,69 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Deletes the given notes and events from the current chart in the chart editor.
+ * Use only when BOTH notes and events are being deleted.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class RemoveItemsCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var events:Array<SongEventData>;
+
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+  {
+    this.notes = notes;
+    this.events = events;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+    state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
+
+    state.currentNoteSelection = [];
+    state.currentEventSelection = [];
+
+    state.playSound(Paths.sound('chartingSounds/noteErase'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    for (note in notes)
+    {
+      state.currentSongChartNoteData.push(note);
+    }
+
+    for (event in events)
+    {
+      state.currentSongChartEventData.push(event);
+    }
+
+    state.currentNoteSelection = notes;
+    state.currentEventSelection = events;
+
+    state.playSound(Paths.sound('chartingSounds/undo'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    return 'Remove ${notes.length + events.length} Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
new file mode 100644
index 000000000..e189be83e
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
@@ -0,0 +1,63 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Deletes the given notes from the current chart in the chart editor.
+ * Use only when ONLY notes are being deleted.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class RemoveNotesCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+
+  public function new(notes:Array<SongNoteData>)
+  {
+    this.notes = notes;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
+    state.currentNoteSelection = [];
+    state.currentEventSelection = [];
+
+    state.playSound(Paths.sound('chartingSounds/noteErase'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    for (note in notes)
+    {
+      state.currentSongChartNoteData.push(note);
+    }
+    state.currentNoteSelection = notes;
+    state.currentEventSelection = [];
+    state.playSound(Paths.sound('chartingSounds/undo'));
+
+    state.saveDataDirty = true;
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+
+    state.sortChartData();
+  }
+
+  public function toString():String
+  {
+    if (notes.length == 1 && notes[0] != null)
+    {
+      var dir:String = notes[0].getDirectionName();
+      return 'Remove $dir Note';
+    }
+
+    return 'Remove ${notes.length} Notes';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx
new file mode 100644
index 000000000..e1a4dceaa
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx
@@ -0,0 +1,42 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+
+/**
+ * Command to set the selection to all notes and events in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SelectAllItemsCommand implements ChartEditorCommand
+{
+  var previousNoteSelection:Array<SongNoteData>;
+  var previousEventSelection:Array<SongEventData>;
+
+  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
+  {
+    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = state.currentSongChartNoteData;
+    state.currentEventSelection = state.currentSongChartEventData;
+
+    state.noteDisplayDirty = true;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = previousNoteSelection;
+    state.currentEventSelection = previousEventSelection;
+
+    state.noteDisplayDirty = true;
+  }
+
+  public function toString():String
+  {
+    return 'Select All Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
new file mode 100644
index 000000000..abe8b9e35
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
@@ -0,0 +1,78 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Appends one or more items to the selection in the chart editor.
+ * This does not deselect any items that are already selected, if any.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SelectItemsCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var events:Array<SongEventData>;
+
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+  {
+    this.notes = notes;
+    this.events = events;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    for (note in this.notes)
+    {
+      state.currentNoteSelection.push(note);
+    }
+
+    for (event in this.events)
+    {
+      state.currentEventSelection.push(event);
+    }
+
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
+    state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
+
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+  }
+
+  public function toString():String
+  {
+    var len:Int = notes.length + events.length;
+
+    if (notes.length == 0)
+    {
+      if (events.length == 1)
+      {
+        return 'Select Event';
+      }
+      else
+      {
+        return 'Select ${events.length} Events';
+      }
+    }
+    else if (events.length == 0)
+    {
+      if (notes.length == 1)
+      {
+        return 'Select Note';
+      }
+      else
+      {
+        return 'Select ${notes.length} Notes';
+      }
+    }
+
+    return 'Select ${len} Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
new file mode 100644
index 000000000..a06aefabc
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
@@ -0,0 +1,48 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+
+/**
+ * Command to set the current selection in the chart editor (rather than appending it).
+ * Deselects any notes that are not in the new selection.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SetItemSelectionCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var events:Array<SongEventData>;
+  var previousNoteSelection:Array<SongNoteData>;
+  var previousEventSelection:Array<SongEventData>;
+
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, previousNoteSelection:Array<SongNoteData>,
+      previousEventSelection:Array<SongEventData>)
+  {
+    this.notes = notes;
+    this.events = events;
+    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
+    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = notes;
+    state.currentEventSelection = events;
+
+    state.noteDisplayDirty = true;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentNoteSelection = previousNoteSelection;
+    state.currentEventSelection = previousEventSelection;
+
+    state.noteDisplayDirty = true;
+  }
+
+  public function toString():String
+  {
+    return 'Select ${notes.length} Items';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx
new file mode 100644
index 000000000..75e7e5afe
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx
@@ -0,0 +1,45 @@
+package funkin.ui.debug.charting.commands;
+
+/**
+ * Switch the current difficulty (and possibly variation) of the chart in the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SwitchDifficultyCommand implements ChartEditorCommand
+{
+  var prevDifficulty:String;
+  var newDifficulty:String;
+  var prevVariation:String;
+  var newVariation:String;
+
+  public function new(prevDifficulty:String, newDifficulty:String, prevVariation:String, newVariation:String)
+  {
+    this.prevDifficulty = prevDifficulty;
+    this.newDifficulty = newDifficulty;
+    this.prevVariation = prevVariation;
+    this.newVariation = newVariation;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    state.selectedVariation = newVariation != null ? newVariation : prevVariation;
+    state.selectedDifficulty = newDifficulty != null ? newDifficulty : prevDifficulty;
+
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.selectedVariation = prevVariation != null ? prevVariation : newVariation;
+    state.selectedDifficulty = prevDifficulty != null ? prevDifficulty : newDifficulty;
+
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+  }
+
+  public function toString():String
+  {
+    return 'Switch Difficulty';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
similarity index 91%
rename from source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
rename to source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
index 021abde0f..2bd719df2 100644
--- a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
@@ -1,4 +1,4 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.components;
 
 import funkin.data.event.SongEventData.SongEventParser;
 import flixel.graphics.frames.FlxAtlasFrames;
@@ -13,7 +13,7 @@ import flixel.math.FlxPoint;
 import funkin.data.song.SongData.SongEventData;
 
 /**
- * A event sprite that can be used to display a song event in a chart.
+ * A sprite that can be used to display a song event in a chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
 @:nullSafety
@@ -34,6 +34,17 @@ class ChartEditorEventSprite extends FlxSprite
    */
   static var eventSpriteBasic:Null<BitmapData> = null;
 
+  public var overrideStepTime(default, set):Null<Float> = null;
+
+  function set_overrideStepTime(value:Null<Float>):Null<Float>
+  {
+    if (overrideStepTime == value) return overrideStepTime;
+
+    overrideStepTime = value;
+    updateEventPosition();
+    return overrideStepTime;
+  }
+
   public function new(parent:ChartEditorState)
   {
     super();
@@ -146,7 +157,7 @@ class ChartEditorEventSprite extends FlxSprite
 
     this.x = (ChartEditorState.STRUMLINE_SIZE * 2 + 1 - 1) * ChartEditorState.GRID_SIZE;
 
-    var stepTime:Float = inline eventData.getStepTime();
+    var stepTime:Float = (overrideStepTime != null) ? overrideStepTime : eventData.getStepTime();
     this.y = stepTime * ChartEditorState.GRID_SIZE;
 
     if (origin != null)
diff --git a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
similarity index 97%
rename from source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
rename to source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
index 59d84647a..0e8c02758 100644
--- a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
@@ -1,4 +1,4 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.components;
 
 import funkin.play.notes.Strumline;
 import funkin.data.notestyle.NoteStyleRegistry;
@@ -11,7 +11,7 @@ import funkin.play.notes.SustainTrail;
 import funkin.data.song.SongData.SongNoteData;
 
 /**
- * A hold note sprite that can be used to display a note in a chart.
+ * A sprite that can be used to display the trail of a hold note in a chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
 @:nullSafety
diff --git a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx
similarity index 98%
rename from source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
rename to source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx
index 6119141cc..7decc8988 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx
@@ -1,4 +1,4 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.components;
 
 import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongNoteData;
diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx
similarity index 85%
rename from source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
rename to source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx
index 77954087b..cd403c6f8 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx
@@ -1,4 +1,4 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.components;
 
 import flixel.FlxObject;
 import flixel.FlxSprite;
@@ -10,10 +10,11 @@ import flixel.math.FlxPoint;
 import funkin.data.song.SongData.SongNoteData;
 
 /**
- * A note sprite that can be used to display a note in a chart.
+ * A sprite that can be used to display a note in a chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
 @:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorNoteSprite extends FlxSprite
 {
   /**
@@ -37,6 +38,28 @@ class ChartEditorNoteSprite extends FlxSprite
    */
   public var noteStyle(get, never):String;
 
+  public var overrideStepTime(default, set):Null<Float> = null;
+
+  function set_overrideStepTime(value:Null<Float>):Null<Float>
+  {
+    if (overrideStepTime == value) return overrideStepTime;
+
+    overrideStepTime = value;
+    updateNotePosition();
+    return overrideStepTime;
+  }
+
+  public var overrideData(default, set):Null<Int> = null;
+
+  function set_overrideData(value:Null<Int>):Null<Int>
+  {
+    if (overrideData == value) return overrideData;
+
+    overrideData = value;
+    playNoteAnimation();
+    return overrideData;
+  }
+
   public function new(parent:ChartEditorState)
   {
     super();
@@ -147,32 +170,15 @@ class ChartEditorNoteSprite extends FlxSprite
   {
     if (this.noteData == null) return;
 
-    var cursorColumn:Int = this.noteData.data;
+    var cursorColumn:Int = (overrideData != null) ? overrideData : this.noteData.data;
 
-    if (cursorColumn < 0) cursorColumn = 0;
-    if (cursorColumn >= (ChartEditorState.STRUMLINE_SIZE * 2 + 1))
-    {
-      cursorColumn = (ChartEditorState.STRUMLINE_SIZE * 2 + 1);
-    }
-    else
-    {
-      // Invert player and opponent columns.
-      if (cursorColumn >= ChartEditorState.STRUMLINE_SIZE)
-      {
-        cursorColumn -= ChartEditorState.STRUMLINE_SIZE;
-      }
-      else
-      {
-        cursorColumn += ChartEditorState.STRUMLINE_SIZE;
-      }
-    }
+    cursorColumn = ChartEditorState.noteDataToGridColumn(cursorColumn);
 
     this.x = cursorColumn * ChartEditorState.GRID_SIZE;
 
     // Notes far in the song will start far down, but the group they belong to will have a high negative offset.
     // noteData.getStepTime() returns a calculated value which accounts for BPM changes
-    var stepTime:Float =
-    inline this.noteData.getStepTime();
+    var stepTime:Float = (overrideStepTime != null) ? overrideStepTime : noteData.getStepTime();
     if (stepTime >= 0)
     {
       this.y = stepTime * ChartEditorState.GRID_SIZE;
@@ -199,7 +205,8 @@ class ChartEditorNoteSprite extends FlxSprite
     var baseAnimationName:String = 'tap';
 
     // Play the appropriate animation for the type, direction, and skin.
-    var animationName:String = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteStyle.toTitleCase()}';
+    var dirName:String = overrideData != null ? SongNoteData.buildDirectionName(overrideData) : this.noteData.getDirectionName();
+    var animationName:String = '${baseAnimationName}${dirName}${this.noteStyle.toTitleCase()}';
 
     this.animation.play(animationName);
 
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
new file mode 100644
index 000000000..8f7c4aaec
--- /dev/null
+++ b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
@@ -0,0 +1,20 @@
+package funkin.ui.debug.charting.components;
+
+import flixel.FlxSprite;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+
+/**
+ * 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
+{
+  public var noteData:Null<SongNoteData>;
+  public var eventData:Null<SongEventData>;
+
+  public function new()
+  {
+    super();
+  }
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
similarity index 75%
rename from source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
rename to source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index b5a6f36be..072004a43 100644
--- a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -1,22 +1,21 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.handlers;
 
 import flixel.system.FlxAssets.FlxSoundAsset;
 import flixel.system.FlxSound;
-import flixel.system.FlxSound;
 import funkin.audio.VoicesGroup;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.util.FileUtil;
+import funkin.util.assets.SoundUtil;
 import haxe.io.Bytes;
 import haxe.io.Path;
 import openfl.utils.Assets;
 
 /**
  * Functions for loading audio for the chart editor.
+ * Handlers split up the functionality of the Chart Editor into different classes based on focus to limit the amount of code in each class.
  */
 @:nullSafety
-@:allow(funkin.ui.debug.charting.ChartEditorState)
-@:allow(funkin.ui.debug.charting.ChartEditorDialogHandler)
-@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler)
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorAudioHandler
 {
   /**
@@ -27,7 +26,7 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = ''):Bool
+  public static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = ''):Bool
   {
     #if sys
     var fileBytes:Bytes = sys.io.File.getBytes(path.toString());
@@ -46,7 +45,7 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = ''):Bool
+  public static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = ''):Bool
   {
     var trackData:Null<Bytes> = Assets.getBytes(path);
     if (trackData != null)
@@ -63,7 +62,7 @@ class ChartEditorAudioHandler
    * @param charId The character this vocal track will be for.
    * @param instId The instrumental this vocal track will be for.
    */
-  static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = ''):Bool
+  public static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = ''):Bool
   {
     var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
     state.audioVocalTrackData.set(trackId, bytes);
@@ -77,7 +76,7 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = ''):Bool
+  public static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = ''):Bool
   {
     #if sys
     var fileBytes:Bytes = sys.io.File.getBytes(path.toString());
@@ -95,7 +94,7 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = ''):Bool
+  public static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = ''):Bool
   {
     var trackData:Null<Bytes> = Assets.getBytes(path);
     if (trackData != null)
@@ -112,7 +111,7 @@ class ChartEditorAudioHandler
    * @param charId The character this vocal track will be for.
    * @param instId The instrumental this vocal track will be for.
    */
-  static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = ''):Bool
+  public static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = ''):Bool
   {
     if (instId == '') instId = 'default';
     state.audioInstTrackData.set(instId, bytes);
@@ -136,11 +135,11 @@ class ChartEditorAudioHandler
   /**
    * Tell the Chart Editor to select a specific instrumental track, that is already loaded.
    */
-  static function playInstrumental(state:ChartEditorState, instId:String = ''):Bool
+  public static function playInstrumental(state:ChartEditorState, instId:String = ''):Bool
   {
     if (instId == '') instId = 'default';
     var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId);
-    var instTrack:Null<FlxSound> = buildFlxSoundFromBytes(instTrackData);
+    var instTrack:Null<FlxSound> = SoundUtil.buildFlxSoundFromBytes(instTrackData);
     if (instTrack == null) return false;
 
     stopExistingInstrumental(state);
@@ -149,7 +148,7 @@ class ChartEditorAudioHandler
     return true;
   }
 
-  static function stopExistingInstrumental(state:ChartEditorState):Void
+  public static function stopExistingInstrumental(state:ChartEditorState):Void
   {
     if (state.audioInstTrack != null)
     {
@@ -162,11 +161,11 @@ class ChartEditorAudioHandler
   /**
    * Tell the Chart Editor to select a specific vocal track, that is already loaded.
    */
-  static function playVocals(state:ChartEditorState, charType:CharacterType, charId:String, instId:String = ''):Bool
+  public static function playVocals(state:ChartEditorState, charType:CharacterType, charId:String, instId:String = ''):Bool
   {
     var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
     var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId);
-    var vocalTrack:Null<FlxSound> = buildFlxSoundFromBytes(vocalTrackData);
+    var vocalTrack:Null<FlxSound> = SoundUtil.buildFlxSoundFromBytes(vocalTrackData);
 
     if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
 
@@ -190,7 +189,7 @@ class ChartEditorAudioHandler
     return false;
   }
 
-  static function stopExistingVocals(state:ChartEditorState):Void
+  public static function stopExistingVocals(state:ChartEditorState):Void
   {
     if (state.audioVocalTrackGroup != null)
     {
@@ -203,7 +202,7 @@ class ChartEditorAudioHandler
    * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
    * @param path The path to the sound effect. Use `Paths` to build this.
    */
-  public static function playSound(path:String):Void
+  public static function playSound(_state:ChartEditorState, path:String):Void
   {
     var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound();
     var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path);
@@ -219,22 +218,11 @@ class ChartEditorAudioHandler
   }
 
   /**
-   * Convert byte data into a playable sound.
-   *
-   * @param input The byte data.
-   * @return The playable sound, or `null` if loading failed.
+   * Create a list of ZIP file entries from the current loaded instrumental tracks in the chart eidtor.
+   * @param state The chart editor state.
+   * @return `Array<haxe.zip.Entry>`
    */
-  public static function buildFlxSoundFromBytes(input:Null<Bytes>):Null<FlxSound>
-  {
-    if (input == null) return null;
-
-    var openflSound:openfl.media.Sound = new openfl.media.Sound();
-    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(input), input.length);
-    var output:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
-    return output;
-  }
-
-  static function makeZIPEntriesFromInstrumentals(state:ChartEditorState):Array<haxe.zip.Entry>
+  public static function makeZIPEntriesFromInstrumentals(state:ChartEditorState):Array<haxe.zip.Entry>
   {
     var zipEntries = [];
 
@@ -257,7 +245,12 @@ class ChartEditorAudioHandler
     return zipEntries;
   }
 
-  static function makeZIPEntriesFromVocals(state:ChartEditorState):Array<haxe.zip.Entry>
+  /**
+   * Create a list of ZIP file entries from the current loaded vocal tracks in the chart eidtor.
+   * @param state The chart editor state.
+   * @return `Array<haxe.zip.Entry>`
+   */
+  public static function makeZIPEntriesFromVocals(state:ChartEditorState):Array<haxe.zip.Entry>
   {
     var zipEntries = [];
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
similarity index 95%
rename from source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
rename to source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index c26f6c805..529707156 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -1,6 +1,5 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.handlers;
 
-import funkin.ui.haxeui.components.FunkinDropDown;
 import flixel.util.FlxTimer;
 import funkin.data.song.importer.FNFLegacyData;
 import funkin.data.song.importer.FNFLegacyImporter;
@@ -15,6 +14,8 @@ import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.song.Song;
 import funkin.play.stage.StageData;
+import funkin.ui.debug.charting.util.ChartEditorDropdowns;
+import funkin.ui.haxeui.components.FunkinDropDown;
 import funkin.ui.haxeui.components.FunkinLink;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
@@ -47,8 +48,10 @@ using Lambda;
  * Handles dialogs for the new Chart Editor.
  */
 @:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorDialogHandler
 {
+  // Paths to HaxeUI layout files for each dialog.
   static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT:String = Paths.ui('chart-editor/dialogs/about');
   static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT:String = Paths.ui('chart-editor/dialogs/welcome');
   static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst');
@@ -67,7 +70,7 @@ class ChartEditorDialogHandler
    * @param state The current chart editor state.
    * @return The dialog that was opened.
    */
-  public static inline function openAboutDialog(state:ChartEditorState):Null<Dialog>
+  public static function openAboutDialog(state:ChartEditorState):Null<Dialog>
   {
     return openDialog(state, CHART_EDITOR_DIALOG_ABOUT_LAYOUT, true, true);
   }
@@ -158,7 +161,7 @@ class ChartEditorDialogHandler
         state.stopWelcomeMusic();
 
         // Load song from template
-        ChartEditorImportExportHandler.loadSongAsTemplate(state, targetSongId);
+        state.loadSongAsTemplate(targetSongId);
       }
 
       splashTemplateContainer.addComponent(linkTemplateSong);
@@ -402,7 +405,7 @@ class ChartEditorDialogHandler
         {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
           if (selectedFile != null && selectedFile.bytes != null)
           {
-            if (ChartEditorAudioHandler.loadInstFromBytes(state, selectedFile.bytes, instId))
+            if (state.loadInstFromBytes(selectedFile.bytes, instId))
             {
               #if !mac
               NotificationManager.instance.addNotification(
@@ -410,7 +413,7 @@ class ChartEditorDialogHandler
                   title: 'Success',
                   body: 'Loaded instrumental track (${selectedFile.name}) for variation (${state.selectedVariation})',
                   type: NotificationType.Success,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
 
@@ -426,7 +429,7 @@ class ChartEditorDialogHandler
                   title: 'Failure',
                   body: 'Failed to load instrumental track (${selectedFile.name}) for variation (${state.selectedVariation})',
                   type: NotificationType.Error,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
             }
@@ -437,7 +440,7 @@ class ChartEditorDialogHandler
     onDropFile = function(pathStr:String) {
       var path:Path = new Path(pathStr);
       trace('Dropped file (${path})');
-      if (ChartEditorAudioHandler.loadInstFromPath(state, path, instId))
+      if (state.loadInstFromPath(path, instId))
       {
         // Tell the user the load was successful.
         #if !mac
@@ -446,7 +449,7 @@ class ChartEditorDialogHandler
             title: 'Success',
             body: 'Loaded instrumental track (${path.file}.${path.ext}) for variation (${state.selectedVariation})',
             type: NotificationType.Success,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
 
@@ -472,7 +475,7 @@ class ChartEditorDialogHandler
             title: 'Failure',
             body: message,
             type: NotificationType.Error,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
       }
@@ -483,66 +486,6 @@ class ChartEditorDialogHandler
     return dialog;
   }
 
-  static var dropHandlers:Array<
-    {
-      component:Component,
-      handler:(String->Void)
-    }> = [];
-
-  /**
-   * Add a callback for when a file is dropped on a component.
-   *
-   * On OS X you can’t drop on the application window, but rather only the app icon
-   * (either in the dock while running or the icon on the hard drive) so this must be disabled
-   * and UI updated appropriately.
-   * @param component
-   * @param handler
-   */
-  static function addDropHandler(component:Component, handler:String->Void):Void
-  {
-    #if desktop
-    if (!FlxG.stage.window.onDropFile.has(onDropFile)) FlxG.stage.window.onDropFile.add(onDropFile);
-
-    dropHandlers.push(
-      {
-        component: component,
-        handler: handler
-      });
-    #else
-    trace('addDropHandler not implemented for this platform');
-    #end
-  }
-
-  static function removeDropHandler(handler:String->Void):Void
-  {
-    #if desktop
-    FlxG.stage.window.onDropFile.remove(handler);
-    #end
-  }
-
-  static function clearDropHandlers():Void
-  {
-    #if desktop
-    dropHandlers = [];
-    FlxG.stage.window.onDropFile.remove(onDropFile);
-    #end
-  }
-
-  static function onDropFile(path:String):Void
-  {
-    // a VERY short timer to wait for the mouse position to update
-    new FlxTimer().start(0.01, function(_) {
-      for (handler in dropHandlers)
-      {
-        if (handler.component.hitTest(FlxG.mouse.screenX, FlxG.mouse.screenY))
-        {
-          handler.handler(path);
-          return;
-        }
-      }
-    });
-  }
-
   /**
    * Opens the dialog in the wizard where the user can set song metadata like name and artist and BPM.
    * @param state The ChartEditorState instance.
@@ -722,7 +665,7 @@ class ChartEditorDialogHandler
     if (dialogNoVocals == null) throw 'Could not locate dialogNoVocals button in Upload Vocals dialog';
     dialogNoVocals.onClick = function(_event) {
       // Dismiss
-      ChartEditorAudioHandler.stopExistingVocals(state);
+      state.stopExistingVocals();
       dialog.hideDialog(DialogButton.APPLY);
     };
 
@@ -749,10 +692,10 @@ class ChartEditorDialogHandler
         if (!hasClearedVocals)
         {
           hasClearedVocals = true;
-          ChartEditorAudioHandler.stopExistingVocals(state);
+          state.stopExistingVocals();
         }
 
-        if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey, instId))
+        if (state.loadVocalsFromPath(path, charKey, instId))
         {
           // Tell the user the load was successful.
           #if !mac
@@ -761,7 +704,7 @@ class ChartEditorDialogHandler
               title: 'Success',
               body: 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${state.selectedVariation}',
               type: NotificationType.Success,
-              expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
             });
           #end
           #if FILE_DROP_SUPPORTED
@@ -784,7 +727,7 @@ class ChartEditorDialogHandler
               title: 'Failure',
               body: 'Failed to load vocal track (${path.file}.${path.ext}) for variation (${state.selectedVariation})',
               type: NotificationType.Error,
-              expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
             });
           #end
 
@@ -805,9 +748,9 @@ class ChartEditorDialogHandler
               if (!hasClearedVocals)
               {
                 hasClearedVocals = true;
-                ChartEditorAudioHandler.stopExistingVocals(state);
+                state.stopExistingVocals();
               }
-              if (ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey, instId))
+              if (state.loadVocalsFromBytes(selectedFile.bytes, charKey, instId))
               {
                 // Tell the user the load was successful.
                 #if !mac
@@ -816,7 +759,7 @@ class ChartEditorDialogHandler
                     title: 'Success',
                     body: 'Loaded vocals for $charName (${selectedFile.name}), variation ${state.selectedVariation}',
                     type: NotificationType.Success,
-                    expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                    expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                   });
                 #end
                 #if FILE_DROP_SUPPORTED
@@ -837,7 +780,7 @@ class ChartEditorDialogHandler
                     title: 'Failure',
                     body: 'Failed to load vocal track (${selectedFile.name}) for variation (${state.selectedVariation})',
                     type: NotificationType.Error,
-                    expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                    expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                   });
                 #end
 
@@ -897,7 +840,7 @@ class ChartEditorDialogHandler
     var buttonContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
     if (buttonContinue == null) throw 'Could not locate dialogContinue button in Open Chart dialog';
     buttonContinue.onClick = function(_event) {
-      ChartEditorImportExportHandler.loadSong(state, songMetadata, songChartData);
+      state.loadSong(songMetadata, songChartData);
 
       dialog.hideDialog(DialogButton.APPLY);
     }
@@ -996,7 +939,7 @@ class ChartEditorDialogHandler
             title: 'Failure',
             body: 'Could not parse metadata file version (${path.file}.${path.ext})',
             type: NotificationType.Error,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
         return;
@@ -1014,7 +957,7 @@ class ChartEditorDialogHandler
             title: 'Failure',
             body: 'Could not load metadata file (${path.file}.${path.ext})',
             type: NotificationType.Error,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
         return;
@@ -1029,7 +972,7 @@ class ChartEditorDialogHandler
           title: 'Success',
           body: 'Loaded metadata file (${path.file}.${path.ext})',
           type: NotificationType.Success,
-          expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          expiryMs: Constants.NOTIFICATION_DISMISS_TIME
         });
       #end
 
@@ -1061,7 +1004,7 @@ class ChartEditorDialogHandler
                   title: 'Failure',
                   body: 'Could not parse metadata file version (${selectedFile.name})',
                   type: NotificationType.Error,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
               return;
@@ -1081,7 +1024,7 @@ class ChartEditorDialogHandler
                   title: 'Success',
                   body: 'Loaded metadata file (${selectedFile.name})',
                   type: NotificationType.Success,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
 
@@ -1102,7 +1045,7 @@ class ChartEditorDialogHandler
                   title: 'Failure',
                   body: 'Failed to load metadata file (${selectedFile.name})',
                   type: NotificationType.Error,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
             }
@@ -1126,7 +1069,7 @@ class ChartEditorDialogHandler
             title: 'Failure',
             body: 'Could not parse chart data file version (${path.file}.${path.ext})',
             type: NotificationType.Error,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
         return;
@@ -1149,7 +1092,7 @@ class ChartEditorDialogHandler
             title: 'Success',
             body: 'Loaded chart data file (${path.file}.${path.ext})',
             type: NotificationType.Success,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
 
@@ -1168,7 +1111,7 @@ class ChartEditorDialogHandler
             title: 'Failure',
             body: 'Failed to load chart data file (${path.file}.${path.ext})',
             type: NotificationType.Error,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
       }
@@ -1193,7 +1136,7 @@ class ChartEditorDialogHandler
                   title: 'Failure',
                   body: 'Could not parse chart data file version (${selectedFile.name})',
                   type: NotificationType.Error,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
               return;
@@ -1216,7 +1159,7 @@ class ChartEditorDialogHandler
                   title: 'Success',
                   body: 'Loaded chart data file (${selectedFile.name})',
                   type: NotificationType.Success,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
 
@@ -1319,7 +1262,7 @@ class ChartEditorDialogHandler
                 title: 'Failure',
                 body: 'Failed to parse FNF chart file (${selectedFile.name})',
                 type: NotificationType.Error,
-                expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                expiryMs: Constants.NOTIFICATION_DISMISS_TIME
               });
             #end
             return;
@@ -1328,7 +1271,7 @@ class ChartEditorDialogHandler
           var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(fnfLegacyData);
           var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(fnfLegacyData);
 
-          ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
+          state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
 
           dialog.hideDialog(DialogButton.APPLY);
           #if !mac
@@ -1337,7 +1280,7 @@ class ChartEditorDialogHandler
               title: 'Success',
               body: 'Loaded chart file (${selectedFile.name})',
               type: NotificationType.Success,
-              expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
             });
           #end
         }
@@ -1351,7 +1294,7 @@ class ChartEditorDialogHandler
       var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(selectedFileData);
       var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(selectedFileData);
 
-      ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
+      state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
 
       dialog.hideDialog(DialogButton.APPLY);
       #if !mac
@@ -1360,7 +1303,7 @@ class ChartEditorDialogHandler
           title: 'Success',
           body: 'Loaded chart file (${path.file}.${path.ext})',
           type: NotificationType.Success,
-          expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          expiryMs: Constants.NOTIFICATION_DISMISS_TIME
         });
       #end
     };
@@ -1376,35 +1319,11 @@ class ChartEditorDialogHandler
    * @param state The current chart editor state.
    * @return The dialog that was opened.
    */
-  public static inline function openUserGuideDialog(state:ChartEditorState):Null<Dialog>
+  public static function openUserGuideDialog(state:ChartEditorState):Null<Dialog>
   {
     return openDialog(state, CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT, true, true);
   }
 
-  /**
-   * Builds and opens a dialog from a given layout path.
-   * @param modal Makes the background uninteractable while the dialog is open.
-   * @param closable Hides the close button on the dialog, preventing it from being closed unless the user interacts with the dialog.
-   */
-  static function openDialog(state:ChartEditorState, key:String, modal:Bool = true, closable:Bool = true):Null<Dialog>
-  {
-    var dialog:Null<Dialog> = cast state.buildComponent(key);
-    if (dialog == null) return null;
-
-    dialog.destroyOnClose = true;
-    dialog.closable = closable;
-    dialog.showDialog(modal);
-
-    state.isHaxeUIDialogOpen = true;
-    dialog.onDialogClosed = function(event:UIEvent) {
-      state.isHaxeUIDialogOpen = false;
-    };
-
-    dialog.zIndex = 1000;
-
-    return dialog;
-  }
-
   /**
    * Builds and opens a dialog where the user can add a new variation for a song.
    * @param state The current chart editor state.
@@ -1561,4 +1480,91 @@ class ChartEditorDialogHandler
 
     return dialog;
   }
+
+  /**
+   * Builds and opens a dialog from a given layout path.
+   * @param modal Makes the background uninteractable while the dialog is open.
+   * @param closable Hides the close button on the dialog, preventing it from being closed unless the user interacts with the dialog.
+   */
+  static function openDialog(state:ChartEditorState, key:String, modal:Bool = true, closable:Bool = true):Null<Dialog>
+  {
+    var dialog:Null<Dialog> = cast state.buildComponent(key);
+    if (dialog == null) return null;
+
+    dialog.destroyOnClose = true;
+    dialog.closable = closable;
+    dialog.showDialog(modal);
+
+    state.isHaxeUIDialogOpen = true;
+    dialog.onDialogClosed = function(event:UIEvent) {
+      state.isHaxeUIDialogOpen = false;
+    };
+
+    dialog.zIndex = 1000;
+
+    return dialog;
+  }
+
+  // ==========
+  // DROP HANDLERS
+  // ==========
+  static var dropHandlers:Array<
+    {
+      component:Component,
+      handler:(String->Void)
+    }> = [];
+
+  /**
+   * Add a callback for when a file is dropped on a component.
+   *
+   * On OS X you can’t drop on the application window, but rather only the app icon
+   * (either in the dock while running or the icon on the hard drive) so this must be disabled
+   * and UI updated appropriately.
+   * @param component
+   * @param handler
+   */
+  static function addDropHandler(component:Component, handler:String->Void):Void
+  {
+    #if desktop
+    if (!FlxG.stage.window.onDropFile.has(onDropFile)) FlxG.stage.window.onDropFile.add(onDropFile);
+
+    dropHandlers.push(
+      {
+        component: component,
+        handler: handler
+      });
+    #else
+    trace('addDropHandler not implemented for this platform');
+    #end
+  }
+
+  static function removeDropHandler(handler:String->Void):Void
+  {
+    #if desktop
+    FlxG.stage.window.onDropFile.remove(handler);
+    #end
+  }
+
+  static function clearDropHandlers():Void
+  {
+    #if desktop
+    dropHandlers = [];
+    FlxG.stage.window.onDropFile.remove(onDropFile);
+    #end
+  }
+
+  static function onDropFile(path:String):Void
+  {
+    // a VERY short timer to wait for the mouse position to update
+    new FlxTimer().start(0.01, function(_) {
+      for (handler in dropHandlers)
+      {
+        if (handler.component.hitTest(FlxG.mouse.screenX, FlxG.mouse.screenY))
+        {
+          handler.handler(path);
+          return;
+        }
+      }
+    });
+  }
 }
diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
similarity index 87%
rename from source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
rename to source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 4d8ff18cb..2e3306769 100644
--- a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -1,4 +1,4 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.handlers;
 
 import haxe.ui.notifications.NotificationType;
 import funkin.util.DateUtil;
@@ -16,7 +16,7 @@ import funkin.data.song.SongRegistry;
  * Contains functions for importing, loading, saving, and exporting charts.
  */
 @:nullSafety
-@:allow(funkin.ui.debug.charting.ChartEditorState)
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorImportExportHandler
 {
   /**
@@ -50,18 +50,18 @@ class ChartEditorImportExportHandler
 
     state.sortChartData();
 
-    state.clearVocals();
+    state.stopExistingVocals();
 
     var variations:Array<String> = state.availableVariations;
     for (variation in variations)
     {
       if (variation == Constants.DEFAULT_VARIATION)
       {
-        ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId));
+        state.loadInstFromAsset(Paths.inst(songId));
       }
       else
       {
-        ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId, '-$variation'), variation);
+        state.loadInstFromAsset(Paths.inst(songId, '-$variation'), variation);
       }
     }
 
@@ -75,12 +75,12 @@ class ChartEditorImportExportHandler
 
       if (voiceList.length == 2)
       {
-        ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId);
-        ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], diff.characters.opponent, instId);
+        state.loadVocalsFromAsset(voiceList[0], diff.characters.player, instId);
+        state.loadVocalsFromAsset(voiceList[1], diff.characters.opponent, instId);
       }
       else if (voiceList.length == 1)
       {
-        ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId);
+        state.loadVocalsFromAsset(voiceList[0], diff.characters.player, instId);
       }
       else
       {
@@ -98,7 +98,7 @@ class ChartEditorImportExportHandler
         title: 'Success',
         body: 'Loaded song (${rawSongMetadata[0].songName})',
         type: NotificationType.Success,
-        expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+        expiryMs: Constants.NOTIFICATION_DISMISS_TIME
       });
     #end
   }
@@ -169,8 +169,8 @@ class ChartEditorImportExportHandler
       }
     }
 
-    if (state.audioInstTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state));
-    if (state.audioVocalTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state));
+    if (state.audioInstTrackData != null) zipEntries.concat(state.makeZIPEntriesFromInstrumentals());
+    if (state.audioVocalTrackData != null) zipEntries.concat(state.makeZIPEntriesFromVocals());
 
     trace('Exporting ${zipEntries.length} files to ZIP...');
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
similarity index 98%
rename from source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx
rename to source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
index 8a9bb8b03..4197ebdd3 100644
--- a/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
@@ -1,26 +1,19 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.handlers;
 
-import flixel.FlxSprite;
 import flixel.addons.display.FlxGridOverlay;
 import flixel.addons.display.FlxSliceSprite;
+import flixel.FlxSprite;
 import flixel.math.FlxRect;
 import flixel.util.FlxColor;
+import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme;
 import openfl.display.BitmapData;
 import openfl.geom.Rectangle;
 
-/**
- * Available themes for the chart editor state.
- */
-enum ChartEditorTheme
-{
-  Light;
-  Dark;
-}
-
 /**
  * Static functions which handle building themed UI elements for a provided ChartEditorState.
  */
 @:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorThemeHandler
 {
   // TODO: There's probably a better system of organization for these colors.
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
similarity index 92%
rename from source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
rename to source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index 7cee1edde..f0c634666 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -1,53 +1,43 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.handlers;
 
-import funkin.ui.haxeui.components.FunkinDropDown;
-import funkin.play.stage.StageData.StageDataParser;
-import funkin.play.stage.StageData;
-import funkin.play.character.CharacterData;
-import funkin.play.character.CharacterData.CharacterDataParser;
-import haxe.ui.components.HorizontalSlider;
-import haxe.ui.containers.TreeView;
-import haxe.ui.containers.TreeViewNode;
-import funkin.play.character.BaseCharacter.CharacterType;
-import funkin.play.event.SongEvent;
 import funkin.data.event.SongEventData;
 import funkin.data.song.SongData.SongTimeChange;
+import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.play.character.CharacterData;
+import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.play.event.SongEvent;
 import funkin.play.song.SongSerializer;
+import funkin.play.stage.StageData;
+import funkin.play.stage.StageData.StageDataParser;
+import funkin.ui.debug.charting.util.ChartEditorDropdowns;
 import funkin.ui.haxeui.components.CharacterPlayer;
+import funkin.ui.haxeui.components.FunkinDropDown;
 import funkin.util.FileUtil;
 import haxe.ui.components.Button;
 import haxe.ui.components.CheckBox;
 import haxe.ui.components.DropDown;
+import haxe.ui.components.HorizontalSlider;
 import haxe.ui.components.Label;
 import haxe.ui.components.NumberStepper;
 import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
 import haxe.ui.containers.Box;
-import haxe.ui.containers.Grid;
-import haxe.ui.containers.Group;
-import haxe.ui.containers.VBox;
-import haxe.ui.containers.Frame;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
 import haxe.ui.containers.dialogs.Dialog.DialogButton;
 import haxe.ui.containers.dialogs.Dialog.DialogEvent;
+import haxe.ui.containers.Frame;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.TreeView;
+import haxe.ui.containers.TreeViewNode;
 import haxe.ui.core.Component;
 import haxe.ui.data.ArrayDataSource;
 import haxe.ui.events.UIEvent;
 
-/**
- * Available tools for the chart editor state.
- */
-enum ChartEditorToolMode
-{
-  Select;
-  Place;
-}
-
 /**
  * Static functions which handle building themed UI elements for a provided ChartEditorState.
  */
 @:nullSafety
-@:allow(funkin.ui.debug.charting.ChartEditorState)
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorToolboxHandler
 {
   public static function setToolboxState(state:ChartEditorState, id:String, shown:Bool):Void
@@ -72,12 +62,10 @@ class ChartEditorToolboxHandler
     {
       toolbox.showDialog(false);
 
-      ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/openWindow'));
+      state.playSound(Paths.sound('chartingSounds/openWindow'));
 
       switch (id)
       {
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:
-          onShowToolboxTools(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
           onShowToolboxNoteData(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
@@ -111,12 +99,10 @@ class ChartEditorToolboxHandler
     {
       toolbox.hideDialog(DialogButton.CANCEL);
 
-      ChartEditorAudioHandler.playSound(Paths.sound('chartingSounds/exitWindow'));
+      state.playSound(Paths.sound('chartingSounds/exitWindow'));
 
       switch (id)
       {
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:
-          onHideToolboxTools(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
           onHideToolboxNoteData(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
@@ -175,8 +161,6 @@ class ChartEditorToolboxHandler
     var toolbox:Null<CollapsibleDialog> = null;
     switch (id)
     {
-      case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:
-        toolbox = buildToolboxToolsLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
         toolbox = buildToolboxNoteDataLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
@@ -223,44 +207,6 @@ class ChartEditorToolboxHandler
     return toolbox;
   }
 
-  static function buildToolboxToolsLayout(state:ChartEditorState):Null<CollapsibleDialog>
-  {
-    var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT);
-
-    if (toolbox == null) return null;
-
-    // Starting position.
-    toolbox.x = 50;
-    toolbox.y = 50;
-
-    toolbox.onDialogClosed = function(event:DialogEvent) {
-      state.setUICheckboxSelected('menubarItemToggleToolboxTools', false);
-    }
-
-    var toolsGroup:Null<Group> = toolbox.findComponent('toolboxToolsGroup', Group);
-    if (toolsGroup == null) throw 'ChartEditorToolboxHandler.buildToolboxToolsLayout() - Could not find toolboxToolsGroup component.';
-
-    if (toolsGroup == null) return null;
-
-    toolsGroup.onChange = function(event:UIEvent) {
-      switch (event.target.id)
-      {
-        case 'toolboxToolsGroupSelect':
-          state.currentToolMode = ChartEditorToolMode.Select;
-        case 'toolboxToolsGroupPlace':
-          state.currentToolMode = ChartEditorToolMode.Place;
-        default:
-          trace('ChartEditorToolboxHandler.buildToolboxToolsLayout() - Unknown toolbox tool selected: $event.target.id');
-      }
-    }
-
-    return toolbox;
-  }
-
-  static function onShowToolboxTools(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
-
-  static function onHideToolboxTools(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
-
   static function buildToolboxNoteDataLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT);
@@ -483,11 +429,11 @@ class ChartEditorToolboxHandler
       throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadChart component.';
 
     difficultyToolboxAddVariation.onClick = function(_:UIEvent) {
-      ChartEditorDialogHandler.openAddVariationDialog(state, true);
+      state.openAddVariationDialog(true);
     };
 
     difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) {
-      ChartEditorDialogHandler.openAddDifficultyDialog(state, true);
+      state.openAddDifficultyDialog(true);
     };
 
     difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) {
diff --git a/source/funkin/ui/debug/charting/import.hx b/source/funkin/ui/debug/charting/import.hx
new file mode 100644
index 000000000..56660c37a
--- /dev/null
+++ b/source/funkin/ui/debug/charting/import.hx
@@ -0,0 +1,10 @@
+package funkin.ui.debug.charting;
+
+#if !macro
+// Apply handlers so they can be called as though they were functions in ChartEditorState
+using funkin.ui.debug.charting.handlers.ChartEditorAudioHandler;
+using funkin.ui.debug.charting.handlers.ChartEditorDialogHandler;
+using funkin.ui.debug.charting.handlers.ChartEditorImportExportHandler;
+using funkin.ui.debug.charting.handlers.ChartEditorThemeHandler;
+using funkin.ui.debug.charting.handlers.ChartEditorToolboxHandler;
+#end
diff --git a/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
similarity index 89%
rename from source/funkin/ui/debug/charting/ChartEditorDropdowns.hx
rename to source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
index ec41de9c0..dfa0408d3 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx
+++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
@@ -1,4 +1,4 @@
-package funkin.ui.debug.charting;
+package funkin.ui.debug.charting.util;
 
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.play.notes.notestyle.NoteStyle;
@@ -10,13 +10,16 @@ import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData.CharacterDataParser;
 
 /**
- * This class contains functions for populating dropdowns based on game data.
+ * Functions for populating dropdowns based on game data.
  * These get used by both dialogs and toolboxes so they're in their own class to prevent "reaching over."
  */
 @:nullSafety
-@:access(ChartEditorState)
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorDropdowns
 {
+  /**
+   * Populate a dropdown with a list of characters.
+   */
   public static function populateDropdownWithCharacters(dropDown:DropDown, charType:CharacterType, startingCharId:String):DropDownEntry
   {
     dropDown.dataSource.clear();
@@ -50,6 +53,9 @@ class ChartEditorDropdowns
     return returnValue;
   }
 
+  /**
+   * Populate a dropdown with a list of stages.
+   */
   public static function populateDropdownWithStages(dropDown:DropDown, startingStageId:String):DropDownEntry
   {
     dropDown.dataSource.clear();
@@ -74,6 +80,9 @@ class ChartEditorDropdowns
     return returnValue;
   }
 
+  /**
+   * Populate a dropdown with a list of note styles.
+   */
   public static function populateDropdownWithNoteStyles(dropDown:DropDown, startingStyleId:String):DropDownEntry
   {
     dropDown.dataSource.clear();
@@ -98,6 +107,9 @@ class ChartEditorDropdowns
     return returnValue;
   }
 
+  /**
+   * Populate a dropdown with a list of song variations.
+   */
   public static function populateDropdownWithVariations(dropDown:DropDown, state:ChartEditorState, includeNone:Bool = true):DropDownEntry
   {
     dropDown.dataSource.clear();
@@ -122,6 +134,9 @@ class ChartEditorDropdowns
   }
 }
 
+/**
+ * An entry in a dropdown.
+ */
 typedef DropDownEntry =
 {
   id:String,
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index edd95f946..ad3b59f6f 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -251,6 +251,16 @@ class Constants
    */
   public static final NS_PER_SEC:Int = NS_PER_US * US_PER_MS * MS_PER_SEC;
 
+  /**
+   * Duration, in milliseconds, until toast notifications are automatically hidden.
+   */
+  public static final NOTIFICATION_DISMISS_TIME:Int = 5 * MS_PER_SEC;
+
+  /**
+   * Duration to wait before autosaving the chart.
+   */
+  public static final AUTOSAVE_TIMER_DELAY_SEC:Float = 5.0 * SECS_PER_MIN;
+
   /**
    * Number of steps in a beat.
    * One step is one 16th note and one beat is one quarter note.
@@ -392,7 +402,8 @@ class Constants
   public static final GHOST_TAPPING:Bool = false;
 
   /**
-   * The separator between an asset library and the asset path.
+    * The separator between an asset library and the asset path.
+
    */
   public static final LIBRARY_SEPARATOR:String = ':';
 
diff --git a/source/funkin/util/assets/SoundUtil.hx b/source/funkin/util/assets/SoundUtil.hx
new file mode 100644
index 000000000..872a61609
--- /dev/null
+++ b/source/funkin/util/assets/SoundUtil.hx
@@ -0,0 +1,23 @@
+package funkin.util.assets;
+
+import haxe.io.Bytes;
+import flixel.system.FlxSound;
+
+class SoundUtil
+{
+  /**
+   * Convert byte data into a playable sound.
+   *
+   * @param input The byte data.
+   * @return The playable sound, or `null` if loading failed.
+   */
+  public static function buildFlxSoundFromBytes(input:Null<Bytes>):Null<FlxSound>
+  {
+    if (input == null) return null;
+
+    var openflSound:openfl.media.Sound = new openfl.media.Sound();
+    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(input), input.length);
+    var output:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
+    return output;
+  }
+}
diff --git a/source/funkin/util/tools/FloatTools.hx b/source/funkin/util/tools/FloatTools.hx
new file mode 100644
index 000000000..e07ae5cb9
--- /dev/null
+++ b/source/funkin/util/tools/FloatTools.hx
@@ -0,0 +1,15 @@
+package funkin.util.tools;
+
+/**
+ * Utilities for performing common math operations.
+ */
+class FloatTools
+{
+  /**
+   * Constrain a float between a minimum and maximum value.
+   */
+  public static function clamp(value:Float, min:Float, max:Float):Float
+  {
+    return Math.max(min, Math.min(max, value));
+  }
+}
diff --git a/source/funkin/util/tools/IntTools.hx b/source/funkin/util/tools/IntTools.hx
new file mode 100644
index 000000000..1d660ad1b
--- /dev/null
+++ b/source/funkin/util/tools/IntTools.hx
@@ -0,0 +1,16 @@
+package funkin.util.tools;
+
+/**
+ * Utilities for performing common math operations.
+ */
+class IntTools
+{
+  /**
+   * Constrain an integer between a minimum and maximum value.
+   */
+  public static function clamp(value:Int, min:Int, max:Int):Int
+  {
+    // Don't use Math.min because it returns a Float.
+    return value < min ? min : value > max ? max : value;
+  }
+}