using UnityEngine; using UnityEditor; using System; using System.Reflection; using System.Collections.Generic; using System.Linq; namespace GeNa.Core { /// /// Manager for UndoPro, enabling an action-based undo workflow integrated into the default Unity Undo system and providing detailed callbacks for the Undo system /// [InitializeOnLoad] public static class UndoProManager { public static bool enabled; private static UnityEngine.Object dummyObject; private static Action getRecordsInternalDelegate; public static UndoProRecords records; public static Action OnUndoPerformed; public static Action OnRedoPerformed; public static Action OnAddUndoRecord; #region General private static void ResetUndoPro() { CreateRecords(); } private static void ToggleUndoPro() { if (!enabled) { EnableUndoPro(); if (SceneView.lastActiveSceneView != null) { SceneView.lastActiveSceneView.ShowNotification(new GUIContent("Undo Pro Enabled!")); SceneView.lastActiveSceneView.Repaint(); } } else { DisableUndoPro(); if (SceneView.lastActiveSceneView != null) { SceneView.lastActiveSceneView.ShowNotification(new GUIContent("Undo Pro Disabled!")); SceneView.lastActiveSceneView.Repaint(); } } } static UndoProManager() { EnableUndoPro(); } public static void EnableUndoPro() { enabled = true; // Assure it is subscribed to all necessary events for undo/redo recognition Undo.undoRedoPerformed -= UndoRedoPerformed; Undo.undoRedoPerformed += UndoRedoPerformed; EditorApplication.update -= Update; EditorApplication.update += Update; EditorApplication.playModeStateChanged -= PlaymodeStateChange; EditorApplication.playModeStateChanged += PlaymodeStateChange; // Fetch Reflection members for Undo interaction Assembly UnityEditorAsssembly = Assembly.GetAssembly(typeof(UnityEditor.Editor)); Type undoType = UnityEditorAsssembly.GetType("UnityEditor.Undo"); #if UNITY_2021_2_OR_NEWER MethodInfo getRecordsInternal = undoType.GetMethod("GetTimelineRecordsInternal", BindingFlags.NonPublic | BindingFlags.Static); #else MethodInfo getRecordsInternal = undoType.GetMethod("GetRecordsInternal", BindingFlags.NonPublic | BindingFlags.Static); #endif getRecordsInternalDelegate = (Action) Delegate.CreateDelegate(typeof(Action), getRecordsInternal); // Create dummy object if (dummyObject == null) dummyObject = new Texture2D(8, 8); // Setup default undo state and record AssureRecords(); } private static void AssureRecords() { if (records == null) { records = GeNaEvents.FindObjectOfType(); if (records == null) CreateRecords(); } if (records.undoState == null) { records.undoState = FetchUndoState(); } } private static void CreateRecords() { if (records != null) UnityEngine.Object.DestroyImmediate(records.gameObject); GameObject recordsGO = new GameObject("UndoProRecords"); records = recordsGO.AddComponent(); } public static void DisableUndoPro() { enabled = false; // Unsubscribe from every event Undo.undoRedoPerformed -= UndoRedoPerformed; EditorApplication.update -= Update; EditorApplication.playModeStateChanged -= PlaymodeStateChange; // Discard now unused objects dummyObject = null; getRecordsInternalDelegate = null; records = null; } #endregion #region Custom Undo Recording private static bool inRecordStack = false; private static bool firstInRecordStack = true; /// /// Begin merging multiple singular undo operations into one group that get's treated as one /// public static void BeginRecordStack() { inRecordStack = true; firstInRecordStack = true; Undo.IncrementCurrentGroup(); } /// /// End merging multiple singular undo operations into one group that get's treated as one /// public static void EndRecordStack() { Undo.FlushUndoRecordObjects(); Undo.IncrementCurrentGroup(); inRecordStack = false; } /// /// Records a custom operation with given label and actions and executes the operation (perform) /// public static void RecordOperationAndPerform(Action perform, Action undo, Action dispose, string label, bool mergeBefore = false, bool mergeAfter = false) { RecordOperation(new UndoProRecord(perform, undo, dispose, label, 0), mergeBefore, mergeAfter); if (perform != null) perform.Invoke(); } /// /// Records a custom operation with given label and actions /// public static void RecordOperation(Action perform, Action undo, Action dispose, string label, bool mergeBefore = false, bool mergeAfter = false, bool updateRecords = true) { RecordOperation(new UndoProRecord(perform, undo, dispose, label, 0), mergeBefore, mergeAfter, updateRecords); } /// /// Records the given operation /// private static void RecordOperation(UndoProRecord operation, bool mergeBefore = false, bool mergeAfter = false, bool updateRecords = true) { if (updateRecords) { // First, make sure the internal records representation is updated UpdateUndoRecords(); } // Make sure this record isn't included in the previous group if (!mergeBefore && !inRecordStack) Undo.IncrementCurrentGroup(); // Create a dummy record with the given label if (dummyObject == null) dummyObject = new Texture2D(8, 8); Undo.RegisterCompleteObjectUndo(dummyObject, operation.label); // Make sure future undo records are not included into this group if (!mergeAfter && !inRecordStack) { Undo.FlushUndoRecordObjects(); Undo.IncrementCurrentGroup(); } // Now get the new Undo state records.undoState = FetchUndoState(); // Record operation internally if (!inRecordStack || firstInRecordStack) records.UndoRecordsAdded(1); firstInRecordStack = false; records.undoProRecords.Add(operation); if (OnAddUndoRecord != null) OnAddUndoRecord.Invoke(new string[] {operation.label}, true); } #endregion #region Undo/Redo Tracking private static bool lastFrameUndoRedoPerformed = false; /// /// Checks if new undo records were added /// private static void Update() { if (!lastFrameUndoRedoPerformed) { // Only handle the case of possible undo addition, but not when an undo or redo was performed UpdateUndoRecords(); } lastFrameUndoRedoPerformed = false; } private static void PlaymodeStateChange(PlayModeStateChange change) { if (change == PlayModeStateChange.EnteredEditMode) UpdateUndoRecords(); } /// /// Check the current undoState for any added undo records and updates the internal records accordingly /// private static void UpdateUndoRecords() { AssureRecords(); // Get new UndoState UndoState prevState = records.undoState; records.undoState = FetchUndoState(); UndoState newState = records.undoState; // Detect additions to the record through comparision of the old and the new UndoState if (prevState.undoRecords.Count == newState.undoRecords.Count) return; // No undo record was added for sure // Fetch new undo records int addedUndoCount = newState.undoRecords.Count - prevState.undoRecords.Count; if (addedUndoCount < 0) { // This happens only when the undo was erased, for example after switching the scene if (newState.undoRecords.Count != 0) { // Attempt to salvage the undo records that are left records.UndoRecordsAdded(addedUndoCount); records.ClearRedo(); //Debug.LogWarning("Cleared Redo because some undos were removed!"); } else CreateRecords(); return; } // Update internals records.UndoRecordsAdded(addedUndoCount); // Callback string[] undosAdded = newState.undoRecords.GetRange(newState.undoRecords.Count - addedUndoCount, addedUndoCount).ToArray(); if (OnAddUndoRecord != null) OnAddUndoRecord.Invoke(undosAdded, newState.redoRecords.Count == 0); } private static UndoState FetchUndoState() { UndoState newUndoState = new UndoState(); getRecordsInternalDelegate.Invoke(newUndoState.undoRecords, newUndoState.redoRecords); return newUndoState; } #endregion #region UndoPro Record tracking /// /// Callback recognising the type of record, calling the apropriate callback and handling undo pro records /// private static void UndoRedoPerformed() { lastFrameUndoRedoPerformed = true; AssureRecords(); // Get new UndoState UndoState prevState = records.undoState; UndoState newState = records.undoState = FetchUndoState(); // Detect undo/redo int addedRecordCount; int change = DetectStateChange(prevState, newState, out addedRecordCount); if (change == 0) // Nothing happend; Only possible if Undo/Redo stack was empty return; List operatedRecords = records.PerformOperationInternal(change, addedRecordCount); if (change < 0) { // UNDO operation foreach (UndoProRecord undoRecord in operatedRecords) { // Invoke undo operations if (undoRecord.undo != null) undoRecord.undo.Invoke(); } // Callback for whole group if (OnUndoPerformed != null) OnUndoPerformed.Invoke(operatedRecords.Select((UndoProRecord record) => record.label).ToArray()); } else { // REDO operation foreach (UndoProRecord redoRecord in operatedRecords) { // Invoke redo operations if (redoRecord.perform != null) redoRecord.perform.Invoke(); } // Callback for whole group if (OnRedoPerformed != null) OnRedoPerformed.Invoke(operatedRecords.Select((UndoProRecord record) => record.label).ToArray()); } } /// /// Detects an UndoState change as either an undo or redo operation /// A positive return value indicates a redo operation, a negative an undo operation; /// If it is 0, then the adressed stack (undo/redo) was empty /// The absolute value is the number of records that were adressed, means the size of the group /// It is possible that the group size changed due to an anomaly, so count of records added by the anomaly is put into addedRecordsCount /// private static int DetectStateChange(UndoState prevState, UndoState nextState, out int addedRecordsCount) { addedRecordsCount = 0; int prevUndoCount = prevState.undoRecords.Count, prevRedoCount = prevState.redoRecords.Count; int nextUndoCount = nextState.undoRecords.Count, nextRedoCount = nextState.redoRecords.Count; int undoChange = nextUndoCount - prevUndoCount, redoChange = nextRedoCount - prevRedoCount; // Check if the action is undo or redo bool undoAction = undoChange < 0, redoAction = redoChange < 0; if ((!redoAction && prevUndoCount == 0) || (!undoAction && prevRedoCount == 0)) // Tried to undo/redo with an empty record stack return 0; if (!undoAction && !redoAction) throw new Exception("Detected neither redo nor undo operation!"); int recordChange = undoAction ? Math.Abs(undoChange) : Math.Abs(redoChange); if (redoChange != -undoChange) { // This anomaly happens only for records that trigger other undo/redo operations // -> only known case: Reparent unselected object in hierarchy, each iteration (undo/redo) of the issued record a 'Parenting' record gets added ontop addedRecordsCount = undoAction ? (Math.Abs(redoChange) - Math.Abs(undoChange)) : (Math.Abs(undoChange) - Math.Abs(redoChange)); } return (undoAction ? -recordChange : recordChange); // Return the count of initially changed records } #endregion } }