|
1 | 1 | import { readFileText } from "../../../src/engine/persistence/FilePersistenceService.js"; |
| 2 | +import { deepClone } from "../../../src/shared/json/clone.js"; |
| 3 | +import { notifyWorkspaceToolDirty } from "../../../src/tools/common/WorkspaceDirtyNotifier.js"; |
2 | 4 |
|
3 | 5 | const TOOL_ID = "midi-studio-v2"; |
4 | 6 | const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; |
@@ -36,6 +38,9 @@ export class MidiStudioV2App { |
36 | 38 | this.instrumentGrid = instrumentGrid; |
37 | 39 | this.instrumentGridParser = instrumentGridParser; |
38 | 40 | this.instrumentGridResults = new WeakMap(); |
| 41 | + this.importedSongBaselines = new Map(); |
| 42 | + this.isDirty = false; |
| 43 | + this.lastSavedToolState = null; |
39 | 44 | this.manifestLoader = manifestLoader; |
40 | 45 | this.midiSourceDetails = midiSourceDetails; |
41 | 46 | this.midiSourceInspection = midiSourceInspection; |
@@ -85,6 +90,8 @@ export class MidiStudioV2App { |
85 | 90 | onToolCopyJson: () => this.copyJson(), |
86 | 91 | onToolExportToolState: () => this.exportToolState(), |
87 | 92 | onToolImportManifest: (file) => this.importManifestFile(file), |
| 93 | + onResetSongEdits: () => this.resetSelectedSongEdits(), |
| 94 | + onSaveProject: () => this.saveProject(), |
88 | 95 | onWorkspaceCopyManifest: () => this.statusLog.info("Workspace copy is owned by Workspace Manager V2."), |
89 | 96 | onWorkspaceExportManifest: () => this.statusLog.info("Workspace export is owned by Workspace Manager V2."), |
90 | 97 | onWorkspaceImportManifest: () => this.statusLog.info("Workspace import is owned by Workspace Manager V2.") |
@@ -141,6 +148,9 @@ export class MidiStudioV2App { |
141 | 148 |
|
142 | 149 | renderEmpty() { |
143 | 150 | this.payload = null; |
| 151 | + this.importedSongBaselines = new Map(); |
| 152 | + this.lastSavedToolState = null; |
| 153 | + this.setDirtyState(false); |
144 | 154 | this.songList.render([], ""); |
145 | 155 | this.details.render(null, null); |
146 | 156 | this.directorPanel.render(null, {}); |
@@ -171,6 +181,9 @@ export class MidiStudioV2App { |
171 | 181 | return false; |
172 | 182 | } |
173 | 183 | this.payload = normalized.payload; |
| 184 | + this.importedSongBaselines = new Map(this.payload.songs.map((song) => [song.id, deepClone(song)])); |
| 185 | + this.lastSavedToolState = null; |
| 186 | + this.setDirtyState(false); |
174 | 187 | this.render(); |
175 | 188 | this.applySelectedSongArrangement("active manifest song"); |
176 | 189 | this.statusLog.ok(`Loaded ${this.payload.songs.length} MIDI song${this.payload.songs.length === 1 ? "" : "s"} from ${sourceLabel} via ${normalized.sourceKind}.`); |
@@ -259,6 +272,27 @@ export class MidiStudioV2App { |
259 | 272 | }; |
260 | 273 | } |
261 | 274 |
|
| 275 | + setDirtyState(isDirty) { |
| 276 | + this.isDirty = isDirty === true; |
| 277 | + this.actionNav.setDirtyState(this.isDirty); |
| 278 | + this.window.document.body.dataset.midiStudioDirty = this.isDirty ? "true" : "false"; |
| 279 | + } |
| 280 | + |
| 281 | + markDirty({ changedKeys = ["data.songs"], reason = "midi-studio-song-updated" } = {}) { |
| 282 | + this.setDirtyState(true); |
| 283 | + const result = notifyWorkspaceToolDirty({ |
| 284 | + changedKeys, |
| 285 | + payload: this.payload, |
| 286 | + reason, |
| 287 | + toolId: TOOL_ID, |
| 288 | + windowRef: this.window |
| 289 | + }); |
| 290 | + if (!result.ok) { |
| 291 | + this.statusLog.warn(`Workspace dirty state not updated: ${result.message}`); |
| 292 | + } |
| 293 | + return result; |
| 294 | + } |
| 295 | + |
262 | 296 | get selectedSongId() { |
263 | 297 | return this.payload?.activeSongId || ""; |
264 | 298 | } |
@@ -420,6 +454,7 @@ export class MidiStudioV2App { |
420 | 454 | this.payload.songs.push(song); |
421 | 455 | this.payload.activeSongId = song.id; |
422 | 456 | } |
| 457 | + this.importedSongBaselines.set(song.id, deepClone(song)); |
423 | 458 | this.render(); |
424 | 459 | this.midiSourceDetails.render(result); |
425 | 460 | this.applySelectedSongArrangement("local MIDI import"); |
@@ -655,6 +690,7 @@ export class MidiStudioV2App { |
655 | 690 | this.instrumentGrid.syncEditedGridResult(result); |
656 | 691 | } |
657 | 692 | this.syncSelectedArrangementFromGridInput(input); |
| 693 | + this.markDirty({ changedKeys: ["data.songs.studioArrangement"], reason: "midi-studio-note-grid-edited" }); |
658 | 694 | if (detail.action === "add-lane") { |
659 | 695 | this.statusLog.ok(`Added instrument row ${detail.laneLabel || detail.lane}; playback data updated.`); |
660 | 696 | } else if (detail.action === "delete-lane") { |
@@ -686,6 +722,31 @@ export class MidiStudioV2App { |
686 | 722 | this.details.showJson(song); |
687 | 723 | } |
688 | 724 |
|
| 725 | + gridInputFromArrangement(arrangement) { |
| 726 | + return { |
| 727 | + bass: arrangement.lanes?.bass || "", |
| 728 | + beatsPerBar: arrangement.beatsPerBar, |
| 729 | + chords: arrangement.lanes?.chords || "", |
| 730 | + drums: arrangement.lanes?.drums || "", |
| 731 | + lanes: arrangement.lanes || {}, |
| 732 | + lead: arrangement.lanes?.lead || "", |
| 733 | + pad: arrangement.lanes?.pad || "", |
| 734 | + previewInstruments: arrangement.previewInstruments || {}, |
| 735 | + sections: arrangement.sections, |
| 736 | + subdivision: arrangement.subdivision |
| 737 | + }; |
| 738 | + } |
| 739 | + |
| 740 | + editableNoteCount(payload = this.payload) { |
| 741 | + return (payload?.songs || []).reduce((count, song) => { |
| 742 | + if (!song.studioArrangement) { |
| 743 | + return count; |
| 744 | + } |
| 745 | + const result = this.instrumentGridParser.parse(this.gridInputFromArrangement(song.studioArrangement)); |
| 746 | + return result.ok ? count + result.timeline.length : count; |
| 747 | + }, 0); |
| 748 | + } |
| 749 | + |
689 | 750 | syncSelectedArrangementFromSongSheetResult(result) { |
690 | 751 | const song = this.selectedSong(); |
691 | 752 | if (!song?.studioArrangement || !result?.ok) { |
@@ -1026,6 +1087,48 @@ export class MidiStudioV2App { |
1026 | 1087 | this.statusLog.ok("MIDI Studio V2 toolState preview written to JSON Details."); |
1027 | 1088 | } |
1028 | 1089 |
|
| 1090 | + saveProject() { |
| 1091 | + if (!this.payload) { |
| 1092 | + this.statusLog.fail("Save Project failed: no MIDI Studio V2 payload is loaded."); |
| 1093 | + return; |
| 1094 | + } |
| 1095 | + try { |
| 1096 | + const toolState = this.serializer.createToolState(this.payload); |
| 1097 | + JSON.stringify(toolState); |
| 1098 | + this.lastSavedToolState = toolState; |
| 1099 | + this.details.showJson(toolState); |
| 1100 | + this.setDirtyState(false); |
| 1101 | + const noteCount = this.editableNoteCount(); |
| 1102 | + this.statusLog.ok(`Save Project completed: ${this.payload.songs.length} song${this.payload.songs.length === 1 ? "" : "s"} saved with ${noteCount} editable note event${noteCount === 1 ? "" : "s"}.`); |
| 1103 | + } catch (error) { |
| 1104 | + this.statusLog.fail(`Save Project failed: ${error.message}`); |
| 1105 | + } |
| 1106 | + } |
| 1107 | + |
| 1108 | + resetSelectedSongEdits() { |
| 1109 | + const song = this.selectedSong(); |
| 1110 | + if (!song) { |
| 1111 | + this.statusLog.fail("Reset Song Edits failed: no MIDI song is selected."); |
| 1112 | + return; |
| 1113 | + } |
| 1114 | + const baseline = this.importedSongBaselines.get(song.id); |
| 1115 | + if (!baseline) { |
| 1116 | + this.statusLog.fail(`Reset Song Edits failed: imported state is unavailable for ${song.name || song.id}.`); |
| 1117 | + return; |
| 1118 | + } |
| 1119 | + const index = this.payload.songs.findIndex((candidate) => candidate.id === song.id); |
| 1120 | + if (index < 0) { |
| 1121 | + this.statusLog.fail(`Reset Song Edits failed: ${song.id} is not in the active MIDI payload.`); |
| 1122 | + return; |
| 1123 | + } |
| 1124 | + this.stopPlayback({ log: false }); |
| 1125 | + this.payload.songs[index] = deepClone(baseline); |
| 1126 | + this.render(); |
| 1127 | + this.applySelectedSongArrangement("Reset Song Edits"); |
| 1128 | + this.setDirtyState(false); |
| 1129 | + this.statusLog.ok(`Reset Song Edits restored ${baseline.name || baseline.id} to imported manifest state.`); |
| 1130 | + } |
| 1131 | + |
1029 | 1132 | async copyJson() { |
1030 | 1133 | if (!this.payload) { |
1031 | 1134 | this.statusLog.fail("Copy blocked: no MIDI Studio V2 payload is loaded."); |
|
0 commit comments