@@ -43,6 +43,17 @@ function normalizeSoundName(name) {
4343 return name . trim ( ) . toLowerCase ( ) ;
4444}
4545
46+ function cloneSoundEntries ( soundEntries ) {
47+ return soundEntries . map ( ( entry ) => ( {
48+ id : entry . id ,
49+ sound : cloneSound ( entry . sound )
50+ } ) ) ;
51+ }
52+
53+ function snapshotsMatch ( left , right ) {
54+ return JSON . stringify ( left ) === JSON . stringify ( right ) ;
55+ }
56+
4657function activeSoundFromToolState ( toolState ) {
4758 return toolState . payload . sounds . find ( ( entry ) => entry . id === toolState . payload . activeSoundId ) ?. sound || null ;
4859}
@@ -90,6 +101,9 @@ export class AudioSfxPlaygroundV2App {
90101 this . soundEntries = [ ] ;
91102 this . statusLog = statusLog ;
92103 this . tileList = tileList ;
104+ this . redoStack = [ ] ;
105+ this . undoStack = [ ] ;
106+ this . historyBaselineSnapshot = null ;
93107 this . window = windowRef ;
94108 this . workspaceDirtyNotifier = workspaceDirtyNotifier ;
95109 }
@@ -108,8 +122,14 @@ export class AudioSfxPlaygroundV2App {
108122 onToolPlay : ( ) => {
109123 void this . play ( ) ;
110124 } ,
125+ onToolRedo : ( ) => {
126+ void this . redo ( ) ;
127+ } ,
111128 onToolStopAll : ( ) => this . stopAll ( ) ,
112129 onToolStop : ( ) => this . stop ( ) ,
130+ onToolUndo : ( ) => {
131+ void this . undo ( ) ;
132+ } ,
113133 onWorkspaceCopyManifest : ( ) => this . statusLog . write ( "Copy manifest action ready for workspace wiring." ) ,
114134 onWorkspaceExportManifest : ( ) => this . statusLog . write ( "Export manifest action ready for workspace wiring." ) ,
115135 onWorkspaceImportManifest : ( ) => this . statusLog . write ( "Import manifest action ready for workspace wiring." )
@@ -126,13 +146,15 @@ export class AudioSfxPlaygroundV2App {
126146 }
127147 } ) ;
128148 this . statusLog . mount ( ) ;
149+ this . refreshHistoryActions ( ) ;
129150 void this . finishStartup ( ) ;
130151 }
131152
132153 async finishStartup ( ) {
133154 await this . loadWorkspacePayload ( ) ;
134155 this . renderSoundList ( ) ;
135156 this . refreshPreview ( ) ;
157+ this . resetHistoryBaseline ( ) ;
136158 this . statusLog . write ( "Audio / SFX Playground V2 ready." ) ;
137159 }
138160
@@ -157,10 +179,7 @@ export class AudioSfxPlaygroundV2App {
157179 this . statusLog . error ( `Workspace payload load failed: ${ validation . message } ` ) ;
158180 return ;
159181 }
160- const nextSoundEntries = validation . value . soundEntries . map ( ( entry ) => ( {
161- id : entry . id ,
162- sound : cloneSound ( entry . sound )
163- } ) ) ;
182+ const nextSoundEntries = cloneSoundEntries ( validation . value . soundEntries ) ;
164183 this . activeSoundId = validation . value . activeSoundId ;
165184 this . soundEntries = nextSoundEntries ;
166185 this . nextSoundNumber = nextSoundNumberAfter ( nextSoundEntries ) ;
@@ -198,14 +217,113 @@ export class AudioSfxPlaygroundV2App {
198217 }
199218
200219 handleEditorChange ( ) {
220+ const beforeSnapshot = this . historyBaselineSnapshot ;
201221 const validation = this . controls . validate ( { nameOverride : this . activeSoundName ( ) } ) ;
202222 if ( validation . valid && this . updateActiveSound ( this . soundForActiveEditorValue ( validation . value ) ) ) {
203223 this . renderSoundList ( ) ;
204- void this . syncWorkspaceDirty ( "audio-sfx-editor-change" , [ "data.sounds" ] ) ;
224+ this . commitUndoableChange ( {
225+ beforeSnapshot,
226+ changedKeys : [ "data.sounds" ] ,
227+ reason : "audio-sfx-editor-change"
228+ } ) ;
229+ }
230+ if ( validation . valid && ! this . activeSoundId ) {
231+ this . commitUndoableChange ( {
232+ beforeSnapshot,
233+ changedKeys : [ "data.sounds" ] ,
234+ reason : "audio-sfx-editor-change" ,
235+ shouldSyncDirty : false
236+ } ) ;
205237 }
206238 this . refreshPreview ( ) ;
207239 }
208240
241+ createHistorySnapshot ( editorSound = null ) {
242+ const validation = editorSound
243+ ? { valid : true , value : editorSound }
244+ : this . controls . validate ( ) ;
245+ if ( ! validation . valid ) {
246+ return null ;
247+ }
248+ return {
249+ activeSoundId : this . activeSoundId ,
250+ editorSound : cloneSound ( validation . value ) ,
251+ editorStyleProfile : this . controls . currentStyleProfile ( ) ,
252+ nextSoundNumber : this . nextSoundNumber ,
253+ soundEntries : cloneSoundEntries ( this . soundEntries )
254+ } ;
255+ }
256+
257+ resetHistoryBaseline ( ) {
258+ this . historyBaselineSnapshot = this . createHistorySnapshot ( ) ;
259+ this . refreshHistoryActions ( ) ;
260+ }
261+
262+ commitUndoableChange ( { beforeSnapshot, changedKeys, reason, shouldSyncDirty = true } ) {
263+ const afterSnapshot = this . createHistorySnapshot ( ) ;
264+ if ( ! beforeSnapshot || ! afterSnapshot || snapshotsMatch ( beforeSnapshot , afterSnapshot ) ) {
265+ this . historyBaselineSnapshot = afterSnapshot ;
266+ this . refreshHistoryActions ( ) ;
267+ return false ;
268+ }
269+ this . undoStack . push ( beforeSnapshot ) ;
270+ this . redoStack = [ ] ;
271+ this . historyBaselineSnapshot = afterSnapshot ;
272+ this . refreshHistoryActions ( ) ;
273+ if ( shouldSyncDirty ) {
274+ void this . syncWorkspaceDirty ( reason , changedKeys ) ;
275+ }
276+ return true ;
277+ }
278+
279+ restoreHistorySnapshot ( snapshot ) {
280+ this . activeSoundId = snapshot . activeSoundId ;
281+ this . soundEntries = cloneSoundEntries ( snapshot . soundEntries ) ;
282+ this . nextSoundNumber = snapshot . nextSoundNumber ;
283+ this . controls . loadSound ( snapshot . editorSound , snapshot . editorStyleProfile ) ;
284+ this . renderSoundList ( ) ;
285+ this . refreshPreview ( ) ;
286+ this . historyBaselineSnapshot = this . createHistorySnapshot ( ) ;
287+ this . refreshHistoryActions ( ) ;
288+ }
289+
290+ refreshHistoryActions ( ) {
291+ this . actionNav . setUndoRedoActionsEnabled ( {
292+ canRedo : this . redoStack . length > 0 ,
293+ canUndo : this . undoStack . length > 0
294+ } ) ;
295+ }
296+
297+ async undo ( ) {
298+ if ( ! this . undoStack . length ) {
299+ this . refreshHistoryActions ( ) ;
300+ return ;
301+ }
302+ const currentSnapshot = this . createHistorySnapshot ( ) ;
303+ const previousSnapshot = this . undoStack . pop ( ) ;
304+ if ( currentSnapshot ) {
305+ this . redoStack . push ( currentSnapshot ) ;
306+ }
307+ this . restoreHistorySnapshot ( previousSnapshot ) ;
308+ await this . syncWorkspaceDirty ( "audio-sfx-undo" , [ "data.sounds" , "data.activeSoundId" ] ) ;
309+ this . statusLog . write ( "Undid last Audio / SFX edit." ) ;
310+ }
311+
312+ async redo ( ) {
313+ if ( ! this . redoStack . length ) {
314+ this . refreshHistoryActions ( ) ;
315+ return ;
316+ }
317+ const currentSnapshot = this . createHistorySnapshot ( ) ;
318+ const nextSnapshot = this . redoStack . pop ( ) ;
319+ if ( currentSnapshot ) {
320+ this . undoStack . push ( currentSnapshot ) ;
321+ }
322+ this . restoreHistorySnapshot ( nextSnapshot ) ;
323+ await this . syncWorkspaceDirty ( "audio-sfx-redo" , [ "data.sounds" , "data.activeSoundId" ] ) ;
324+ this . statusLog . write ( "Redid last Audio / SFX edit." ) ;
325+ }
326+
209327 async syncWorkspaceDirty ( reason , changedKeys ) {
210328 if ( typeof this . workspaceDirtyNotifier !== "function" ) {
211329 return ;
@@ -296,10 +414,15 @@ export class AudioSfxPlaygroundV2App {
296414 return ;
297415 }
298416
417+ const beforeSnapshot = this . createHistorySnapshot ( ) ;
299418 const entry = this . createSoundEntry ( validation . value ) ;
300419 this . renderSoundList ( ) ;
301420 this . refreshPreview ( ) ;
302- void this . syncWorkspaceDirty ( "audio-sfx-sound-added" , [ "data.sounds" , "data.activeSoundId" ] ) ;
421+ this . commitUndoableChange ( {
422+ beforeSnapshot,
423+ changedKeys : [ "data.sounds" , "data.activeSoundId" ] ,
424+ reason : "audio-sfx-sound-added"
425+ } ) ;
303426 this . statusLog . write ( `Added ${ entry . sound . name } .` ) ;
304427 }
305428
@@ -321,11 +444,16 @@ export class AudioSfxPlaygroundV2App {
321444 this . controls . showMessage ( "Name must be unique." , true ) ;
322445 return ;
323446 }
447+ const beforeSnapshot = this . createHistorySnapshot ( entry . sound ) ;
324448 this . audioEngine . stop ( entry . sound . name ) ;
325449 entry . sound . name = nextName ;
326450 this . renderSoundList ( ) ;
327451 this . refreshPreview ( ) ;
328- void this . syncWorkspaceDirty ( "audio-sfx-sound-renamed" , [ "data.sounds" ] ) ;
452+ this . commitUndoableChange ( {
453+ beforeSnapshot,
454+ changedKeys : [ "data.sounds" ] ,
455+ reason : "audio-sfx-sound-renamed"
456+ } ) ;
329457 this . statusLog . write ( `Renamed SFX to ${ entry . sound . name } .` ) ;
330458 }
331459
@@ -339,6 +467,7 @@ export class AudioSfxPlaygroundV2App {
339467 this . controls . loadSound ( entry . sound ) ;
340468 this . renderSoundList ( ) ;
341469 this . refreshPreview ( ) ;
470+ this . resetHistoryBaseline ( ) ;
342471 this . statusLog . write ( `Loaded ${ entry . sound . name } .` ) ;
343472 try {
344473 const playbackResult = await this . audioEngine . play ( entry . sound ) ;
@@ -420,6 +549,7 @@ export class AudioSfxPlaygroundV2App {
420549 this . controls . setDeleteEnabled ( false ) ;
421550 return ;
422551 }
552+ const beforeSnapshot = this . createHistorySnapshot ( ) ;
423553 const [ entry ] = this . soundEntries . splice ( entryIndex , 1 ) ;
424554 this . audioEngine . stop ( entry . sound . name ) ;
425555 const nextEntry = this . soundEntries [ Math . min ( entryIndex , this . soundEntries . length - 1 ) ] || null ;
@@ -429,7 +559,11 @@ export class AudioSfxPlaygroundV2App {
429559 }
430560 this . renderSoundList ( ) ;
431561 this . refreshPreview ( ) ;
432- void this . syncWorkspaceDirty ( "audio-sfx-sound-deleted" , [ "data.sounds" , "data.activeSoundId" ] ) ;
562+ this . commitUndoableChange ( {
563+ beforeSnapshot,
564+ changedKeys : [ "data.sounds" , "data.activeSoundId" ] ,
565+ reason : "audio-sfx-sound-deleted"
566+ } ) ;
433567 this . statusLog . write ( `Deleted ${ entry . sound . name } .` ) ;
434568 }
435569
@@ -443,6 +577,7 @@ export class AudioSfxPlaygroundV2App {
443577 return { valid : false , message : activeSoundEntry . message , toolState : null , json : "" } ;
444578 }
445579 this . renderSoundList ( ) ;
580+ this . resetHistoryBaseline ( ) ;
446581 const toolState = this . serializer . createToolState ( {
447582 activeSoundId : this . activeSoundId ,
448583 soundEntries : this . soundEntries
@@ -486,17 +621,19 @@ export class AudioSfxPlaygroundV2App {
486621 this . statusLog . error ( `Import JSON failed: ${ result . message } ` ) ;
487622 return ;
488623 }
489- const nextSoundEntries = result . value . soundEntries . map ( ( entry ) => ( {
490- id : entry . id ,
491- sound : cloneSound ( entry . sound )
492- } ) ) ;
624+ const beforeSnapshot = this . createHistorySnapshot ( ) ;
625+ const nextSoundEntries = cloneSoundEntries ( result . value . soundEntries ) ;
493626 this . activeSoundId = result . value . activeSoundId ;
494627 this . soundEntries = nextSoundEntries ;
495628 this . nextSoundNumber = nextSoundNumberAfter ( nextSoundEntries ) ;
496629 this . controls . loadSound ( result . value . sound ) ;
497630 this . renderSoundList ( ) ;
498631 this . refreshPreview ( ) ;
499- await this . syncWorkspaceDirty ( "audio-sfx-json-imported" , [ "data.sounds" , "data.activeSoundId" ] ) ;
632+ this . commitUndoableChange ( {
633+ beforeSnapshot,
634+ changedKeys : [ "data.sounds" , "data.activeSoundId" ] ,
635+ reason : "audio-sfx-json-imported"
636+ } ) ;
500637 this . statusLog . write ( `Imported JSON from ${ file . name } .` ) ;
501638 } catch ( error ) {
502639 this . statusLog . error ( `Import JSON failed: ${ error . message } ` ) ;
0 commit comments