Files
2026-02-28 12:43:44 +08:00

336 lines
14 KiB
C#

using UnityEngine;
using UnityEditor;
using System;
using System.Reflection;
using System.Collections.Generic;
using System.Linq;
namespace GeNa.Core
{
/// <summary>
/// Manager for UndoPro, enabling an action-based undo workflow integrated into the default Unity Undo system and providing detailed callbacks for the Undo system
/// </summary>
[InitializeOnLoad]
public static class UndoProManager
{
public static bool enabled;
private static UnityEngine.Object dummyObject;
private static Action<object, object> getRecordsInternalDelegate;
public static UndoProRecords records;
public static Action<string[]> OnUndoPerformed;
public static Action<string[]> OnRedoPerformed;
public static Action<string[], bool> 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<object, object>) Delegate.CreateDelegate(typeof(Action<object, object>), 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<UndoProRecords>();
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<UndoProRecords>();
}
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;
/// <summary>
/// Begin merging multiple singular undo operations into one group that get's treated as one
/// </summary>
public static void BeginRecordStack()
{
inRecordStack = true;
firstInRecordStack = true;
Undo.IncrementCurrentGroup();
}
/// <summary>
/// End merging multiple singular undo operations into one group that get's treated as one
/// </summary>
public static void EndRecordStack()
{
Undo.FlushUndoRecordObjects();
Undo.IncrementCurrentGroup();
inRecordStack = false;
}
/// <summary>
/// Records a custom operation with given label and actions and executes the operation (perform)
/// </summary>
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();
}
/// <summary>
/// Records a custom operation with given label and actions
/// </summary>
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);
}
/// <summary>
/// Records the given operation
/// </summary>
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;
/// <summary>
/// Checks if new undo records were added
/// </summary>
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();
}
/// <summary>
/// Check the current undoState for any added undo records and updates the internal records accordingly
/// </summary>
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
/// <summary>
/// Callback recognising the type of record, calling the apropriate callback and handling undo pro records
/// </summary>
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<UndoProRecord> 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());
}
}
/// <summary>
/// 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
/// </summary>
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
}
}