initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
/**
* Base command interface for the command pattern implementation
* All undoable operations in KGSP should extend this class
*/
export abstract class KGCommand {
/**
* Execute the command (perform the operation)
*/
abstract execute(): void;
/**
* Undo the command (reverse the operation)
*/
abstract undo(): void;
/**
* Get a human-readable description of what the command does
* Used for UI feedback and debugging
*/
abstract getDescription(): string;
/**
* Check if this command can be merged with another command
* Used for continuous operations like dragging or resizing
* @param other The other command to potentially merge with
*/
canMergeWith(other: KGCommand): boolean {
return false; // Default: commands cannot be merged
}
/**
* Merge this command with another command
* Only called if canMergeWith returns true
* @param other The other command to merge with
* @returns A new merged command, or null if merge fails
*/
mergeWith(other: KGCommand): KGCommand | null {
return null; // Default: no merging implemented
}
/**
* Get the timestamp when this command was created
* Used for command history management
*/
public readonly timestamp: number = Date.now();
}
+239
View File
@@ -0,0 +1,239 @@
import { KGCommand } from './KGCommand';
import { DEBUG_MODE } from '../../constants';
/**
* Manages the command history for undo/redo functionality
* Implements a singleton pattern to ensure single source of truth
*/
export class KGCommandHistory {
private static _instance: KGCommandHistory | null = null;
private undoStack: KGCommand[] = [];
private redoStack: KGCommand[] = [];
private maxHistorySize: number = 30;
// Callbacks for UI updates
private onHistoryChanged?: () => void;
private constructor() {
// Private constructor for singleton pattern
}
/**
* Get the singleton instance
*/
public static instance(): KGCommandHistory {
if (!this._instance) {
this._instance = new KGCommandHistory();
}
return this._instance;
}
/**
* Execute a command and add it to the history
* @param command The command to execute
*/
public executeCommand(command: KGCommand): void {
try {
// Execute the command
command.execute();
// Check if we can merge with the last command
if (this.undoStack.length > 0) {
const lastCommand = this.undoStack[this.undoStack.length - 1];
if (lastCommand.canMergeWith(command)) {
const mergedCommand = lastCommand.mergeWith(command);
if (mergedCommand) {
// Replace the last command with the merged one
this.undoStack[this.undoStack.length - 1] = mergedCommand;
if (DEBUG_MODE.CORE) {
console.log(`Merged command: ${mergedCommand.getDescription()}`);
}
this.notifyHistoryChanged();
return;
}
}
}
// Add to undo stack
this.undoStack.push(command);
// Clear redo stack since we're executing a new command
this.redoStack = [];
// Maintain history size limit
if (this.undoStack.length > this.maxHistorySize) {
this.undoStack.shift();
}
if (DEBUG_MODE.CORE) {
console.log(`Executed command: ${command.getDescription()}`);
console.log(`Undo stack size: ${this.undoStack.length}`);
}
this.notifyHistoryChanged();
} catch (error) {
console.error('Failed to execute command:', error);
// Don't add failed commands to history
}
}
/**
* Undo the last command
* @returns true if undo was successful, false otherwise
*/
public undo(): boolean {
if (this.undoStack.length === 0) {
return false;
}
const command = this.undoStack.pop()!;
try {
command.undo();
this.redoStack.push(command);
if (DEBUG_MODE.CORE) {
console.log(`Undid command: ${command.getDescription()}`);
console.log(`Undo stack size: ${this.undoStack.length}, Redo stack size: ${this.redoStack.length}`);
}
this.notifyHistoryChanged();
return true;
} catch (error) {
console.error('Failed to undo command:', error);
// Put the command back on the stack if undo fails
this.undoStack.push(command);
return false;
}
}
/**
* Redo the last undone command
* @returns true if redo was successful, false otherwise
*/
public redo(): boolean {
if (this.redoStack.length === 0) {
return false;
}
const command = this.redoStack.pop()!;
try {
command.execute();
this.undoStack.push(command);
if (DEBUG_MODE.CORE) {
console.log(`Redid command: ${command.getDescription()}`);
console.log(`Undo stack size: ${this.undoStack.length}, Redo stack size: ${this.redoStack.length}`);
}
this.notifyHistoryChanged();
return true;
} catch (error) {
console.error('Failed to redo command:', error);
// Put the command back on the redo stack if redo fails
this.redoStack.push(command);
return false;
}
}
/**
* Check if undo is available
*/
public canUndo(): boolean {
return this.undoStack.length > 0;
}
/**
* Check if redo is available
*/
public canRedo(): boolean {
return this.redoStack.length > 0;
}
/**
* Get description of the next command that would be undone
*/
public getUndoDescription(): string | null {
if (this.undoStack.length === 0) {
return null;
}
return this.undoStack[this.undoStack.length - 1].getDescription();
}
/**
* Get description of the next command that would be redone
*/
public getRedoDescription(): string | null {
if (this.redoStack.length === 0) {
return null;
}
return this.redoStack[this.redoStack.length - 1].getDescription();
}
/**
* Clear all command history
*/
public clear(): void {
this.undoStack = [];
this.redoStack = [];
if (DEBUG_MODE.CORE) {
console.log('Cleared command history');
}
this.notifyHistoryChanged();
}
/**
* Set the maximum number of commands to keep in history
* @param size Maximum history size (default: 30)
*/
public setMaxHistorySize(size: number): void {
this.maxHistorySize = Math.max(1, size);
// Trim history if needed
while (this.undoStack.length > this.maxHistorySize) {
this.undoStack.shift();
}
this.notifyHistoryChanged();
}
/**
* Get current history size limit
*/
public getMaxHistorySize(): number {
return this.maxHistorySize;
}
/**
* Set callback for when history changes (for UI updates)
*/
public setOnHistoryChanged(callback: () => void): void {
this.onHistoryChanged = callback;
}
/**
* Get current history statistics for debugging
*/
public getHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
return {
undoCount: this.undoStack.length,
redoCount: this.redoStack.length,
maxSize: this.maxHistorySize
};
}
/**
* Notify listeners that history has changed
*/
private notifyHistoryChanged(): void {
if (this.onHistoryChanged) {
this.onHistoryChanged();
}
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Command system exports
* Provides centralized access to command pattern classes
*/
export { KGCommand } from './KGCommand';
export { KGCommandHistory } from './KGCommandHistory';
// Track commands
export { AddTrackCommand } from './track/AddTrackCommand';
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
// Region commands
export { CreateRegionCommand } from './region/CreateRegionCommand';
export { DeleteRegionCommand, DeleteMultipleRegionsCommand } from './region/DeleteRegionCommand';
export { ResizeRegionCommand } from './region/ResizeRegionCommand';
export { MoveRegionCommand } from './region/MoveRegionCommand';
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';
export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand';
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
export { MoveNotesCommand } from './note/MoveNotesCommand';
export { PasteNotesCommand } from './note/PasteNotesCommand';
// Project commands
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
+173
View File
@@ -0,0 +1,173 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { generateUniqueId } from '../../../util/miscUtil';
/**
* Command to create a new MIDI note in a region
* Handles both the core model update and provides undo functionality
*/
export class CreateNoteCommand extends KGCommand {
private regionId: string;
private noteId: string;
private startBeat: number;
private endBeat: number;
private pitch: number;
private velocity: number;
private createdNote: KGMidiNote | null = null;
constructor(
regionId: string,
startBeat: number,
endBeat: number,
pitch: number,
velocity: number = 127,
noteId?: string
) {
super();
this.regionId = regionId;
this.startBeat = startBeat;
this.endBeat = endBeat;
this.pitch = pitch;
this.velocity = velocity;
this.noteId = noteId || generateUniqueId('KGMidiNote');
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the region that contains this note
let targetRegion: KGMidiRegion | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId);
if (region && region instanceof KGMidiRegion) {
targetRegion = region;
break;
}
}
if (!targetRegion) {
throw new Error(`MIDI region with ID ${this.regionId} not found`);
}
// Create the new MIDI note
this.createdNote = new KGMidiNote(
this.noteId,
this.startBeat,
this.endBeat,
this.pitch,
this.velocity
);
// Add the note to the region
targetRegion.addNote(this.createdNote);
console.log(`Created note in region ${this.regionId}: pitch=${this.pitch}, start=${this.startBeat}, end=${this.endBeat}`);
}
undo(): void {
if (!this.createdNote) {
throw new Error('Cannot undo: no note was created');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the region that contains this note
let targetRegion: KGMidiRegion | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId);
if (region && region instanceof KGMidiRegion) {
targetRegion = region;
break;
}
}
if (!targetRegion) {
throw new Error(`MIDI region with ID ${this.regionId} not found during undo`);
}
// Remove the note from the region
targetRegion.removeNote(this.noteId);
console.log(`Removed note from region ${this.regionId}: pitch=${this.pitch}`);
}
getDescription(): string {
// Convert MIDI pitch to note name for user-friendly description
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(this.pitch / 12) - 1;
const noteName = noteNames[this.pitch % 12];
return `Create note ${noteName}${octave}`;
}
/**
* Get the ID of the note that was/will be created
*/
public getNoteId(): string {
return this.noteId;
}
/**
* Get the created note instance (only available after execute)
*/
public getCreatedNote(): KGMidiNote | null {
return this.createdNote;
}
/**
* Get the region ID where the note was/will be created
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Factory method to create a note command from UI coordinates
*/
public static fromUICoordinates(
regionId: string,
mouseX: number,
mouseY: number,
pianoGridElement: HTMLElement,
beatWidth: number,
noteHeight: number,
regionStartBeat: number,
noteLength: number,
velocity: number = 127
): CreateNoteCommand {
// Calculate relative position within the grid
const rect = pianoGridElement.getBoundingClientRect();
const x = mouseX - rect.left;
const y = mouseY - rect.top;
// Calculate the beat number (relative to region start)
const rawBeatNumber = x / beatWidth;
const beatNumber = Math.floor(rawBeatNumber); // Snap to beat grid
// Calculate the pitch (MIDI note number)
// The piano roll is drawn from bottom to top, with higher notes at the top
const pitchIndex = Math.floor(y / noteHeight);
const pitch = 107 - pitchIndex; // Convert index to pitch (B7 is 107)
// Calculate note start and end beats
const noteStartBeat = beatNumber;
const noteEndBeat = noteStartBeat + noteLength;
return new CreateNoteCommand(
regionId,
noteStartBeat,
noteEndBeat,
pitch,
velocity
);
}
}
@@ -0,0 +1,258 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { generateUniqueId } from '../../../util/miscUtil';
/**
* Data structure for a note to be created
*/
export interface NoteCreationData {
regionId: string;
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
noteId?: string;
}
/**
* Command to create multiple MIDI notes in regions
* Handles bulk creation as a single undoable operation
*/
export class CreateNotesCommand extends KGCommand {
private noteCreationData: NoteCreationData[];
private createdNotes: Array<{
note: KGMidiNote;
regionId: string;
}> = [];
constructor(noteCreationData: NoteCreationData[]) {
super();
this.noteCreationData = noteCreationData.map(data => ({
...data,
noteId: data.noteId || generateUniqueId('KGMidiNote')
}));
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Clear any existing created note data to prevent duplicates on re-execution
this.createdNotes = [];
// Create all notes
for (const noteData of this.noteCreationData) {
// Find the target region
let targetRegion: KGMidiRegion | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === noteData.regionId);
if (region && region instanceof KGMidiRegion) {
targetRegion = region;
break;
}
}
if (!targetRegion) {
throw new Error(`MIDI region with ID ${noteData.regionId} not found`);
}
// Create the new MIDI note
const newNote = new KGMidiNote(
noteData.noteId!,
noteData.startBeat,
noteData.endBeat,
noteData.pitch,
noteData.velocity
);
// Add the note to the region
targetRegion.addNote(newNote);
// Store for undo
this.createdNotes.push({
note: newNote,
regionId: noteData.regionId
});
}
const noteCount = this.createdNotes.length;
const regionCount = new Set(this.createdNotes.map(data => data.regionId)).size;
console.log(`Created ${noteCount} notes in ${regionCount} region${regionCount > 1 ? 's' : ''}`);
}
undo(): void {
if (this.createdNotes.length === 0) {
throw new Error('Cannot undo: no notes were created');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Remove all created notes from their regions
for (const data of this.createdNotes) {
// Find the region
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === data.regionId);
if (region && region instanceof KGMidiRegion) {
region.removeNote(data.note.getId());
// Clear selection if this note was selected
const selectedItems = core.getSelectedItems();
const selectedNote = selectedItems.find(item =>
item instanceof KGMidiNote && item.getId() === data.note.getId()
);
if (selectedNote) {
core.removeSelectedItem(selectedNote);
}
break;
}
}
}
console.log(`Removed ${this.createdNotes.length} created notes from ${new Set(this.createdNotes.map(d => d.regionId)).size} regions`);
}
getDescription(): string {
if (this.noteCreationData.length === 1) {
const noteData = this.noteCreationData[0];
// Convert MIDI pitch to note name for user-friendly description
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(noteData.pitch / 12) - 1;
const noteName = noteNames[noteData.pitch % 12];
return `Create note ${noteName}${octave}`;
}
return `Create ${this.noteCreationData.length} notes`;
}
/**
* Get the note creation data that was/will be processed
*/
public getNoteCreationData(): NoteCreationData[] {
return this.noteCreationData;
}
/**
* Get the created note instances (only available after execute)
*/
public getCreatedNotes(): Array<{note: KGMidiNote; regionId: string}> {
return this.createdNotes;
}
/**
* Get the regions that were affected by this creation
*/
public getAffectedRegionIds(): string[] {
return Array.from(new Set(this.noteCreationData.map(data => data.regionId)));
}
/**
* Get the IDs of notes that were/will be created
*/
public getCreatedNoteIds(): string[] {
return this.noteCreationData.map(data => data.noteId!);
}
}
/**
* Command to create a single MIDI note (convenience wrapper)
*/
export class CreateNoteCommand extends CreateNotesCommand {
constructor(
regionId: string,
startBeat: number,
endBeat: number,
pitch: number,
velocity: number = 127,
noteId?: string
) {
super([{
regionId,
startBeat,
endBeat,
pitch,
velocity,
noteId
}]);
}
getDescription(): string {
const noteData = this.getNoteCreationData()[0];
// Convert MIDI pitch to note name for user-friendly description
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(noteData.pitch / 12) - 1;
const noteName = noteNames[noteData.pitch % 12];
return `Create note ${noteName}${octave}`;
}
/**
* Get the ID of the note that was/will be created
*/
public getNoteId(): string {
return this.getCreatedNoteIds()[0];
}
/**
* Get the created note instance (only available after execute)
*/
public getCreatedNote(): KGMidiNote | null {
const createdNotes = this.getCreatedNotes();
return createdNotes.length > 0 ? createdNotes[0].note : null;
}
/**
* Get the region ID where the note was/will be created
*/
public getRegionId(): string {
return this.getNoteCreationData()[0].regionId;
}
/**
* Factory method to create a note command from UI coordinates
*/
public static fromUICoordinates(
regionId: string,
mouseX: number,
mouseY: number,
pianoGridElement: HTMLElement,
beatWidth: number,
noteHeight: number,
regionStartBeat: number,
noteLength: number,
velocity: number = 127
): CreateNoteCommand {
// Calculate relative position within the grid
const rect = pianoGridElement.getBoundingClientRect();
const x = mouseX - rect.left;
const y = mouseY - rect.top;
// Calculate the beat number (relative to region start)
const rawBeatNumber = x / beatWidth;
const beatNumber = Math.floor(rawBeatNumber); // Snap to beat grid
// Calculate the pitch (MIDI note number)
// The piano roll is drawn from bottom to top, with higher notes at the top
const pitchIndex = Math.floor(y / noteHeight);
const pitch = 107 - pitchIndex; // Convert index to pitch (B7 is 107)
// Calculate note start and end beats
const noteStartBeat = beatNumber;
const noteEndBeat = noteStartBeat + noteLength;
return new CreateNoteCommand(
regionId,
noteStartBeat,
noteEndBeat,
pitch,
velocity
);
}
}
@@ -0,0 +1,183 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiRegion } from '../../region/KGMidiRegion';
/**
* Command to delete multiple MIDI notes from regions
* Handles bulk deletion as a single undoable operation
*/
export class DeleteNotesCommand extends KGCommand {
private noteIds: string[];
private deletedNoteData: Array<{
note: KGMidiNote;
regionId: string;
originalIndex: number;
}> = [];
constructor(noteIds: string[]) {
super();
this.noteIds = noteIds;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Clear any existing deleted note data to prevent duplicates on re-execution
this.deletedNoteData = [];
// Find and store all notes to delete
for (const noteId of this.noteIds) {
for (const track of tracks) {
const regions = track.getRegions();
for (const region of regions) {
if (region instanceof KGMidiRegion) {
const notes = region.getNotes();
const noteIndex = notes.findIndex(note => note.getId() === noteId);
if (noteIndex !== -1) {
const noteToDelete = notes[noteIndex];
// Store for undo
this.deletedNoteData.push({
note: noteToDelete,
regionId: region.getId(),
originalIndex: noteIndex
});
break; // Found the note, move to next noteId
}
}
}
}
}
if (this.deletedNoteData.length === 0) {
throw new Error('No notes found to delete');
}
// Sort by original index in descending order to maintain correct indices during deletion
this.deletedNoteData.sort((a, b) => b.originalIndex - a.originalIndex);
// Delete all notes from their regions
for (const data of this.deletedNoteData) {
// Find the region again
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === data.regionId);
if (region && region instanceof KGMidiRegion) {
region.removeNote(data.note.getId());
// Clear selection if this note was selected
const selectedItems = core.getSelectedItems();
const selectedNote = selectedItems.find(item =>
item instanceof KGMidiNote && item.getId() === data.note.getId()
);
if (selectedNote) {
core.removeSelectedItem(selectedNote);
}
break;
}
}
}
console.log(`Deleted ${this.deletedNoteData.length} notes from ${new Set(this.deletedNoteData.map(d => d.regionId)).size} regions`);
}
undo(): void {
if (this.deletedNoteData.length === 0) {
throw new Error('Cannot undo: no note data stored');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Restore all notes in reverse order (original index ascending)
const sortedData = [...this.deletedNoteData].sort((a, b) => a.originalIndex - b.originalIndex);
for (const data of sortedData) {
// Find the target region
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === data.regionId);
if (region && region instanceof KGMidiRegion) {
const notes = region.getNotes();
// Insert note at original position
if (data.originalIndex >= 0 && data.originalIndex <= notes.length) {
notes.splice(data.originalIndex, 0, data.note);
region.setNotes(notes);
} else {
// Fallback: add to the end
region.addNote(data.note);
}
break;
}
}
}
console.log(`Restored ${this.deletedNoteData.length} notes to their original positions`);
}
getDescription(): string {
if (this.deletedNoteData.length === 1) {
const note = this.deletedNoteData[0].note;
// Convert MIDI pitch to note name for user-friendly description
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(note.getPitch() / 12) - 1;
const noteName = noteNames[note.getPitch() % 12];
return `Delete note ${noteName}${octave}`;
}
return `Delete ${this.noteIds.length} notes`;
}
/**
* Get the IDs of notes that were/will be deleted
*/
public getNoteIds(): string[] {
return this.noteIds;
}
/**
* Get the deleted note data (only available after execute)
*/
public getDeletedNoteData(): Array<{note: KGMidiNote; regionId: string; originalIndex: number}> {
return this.deletedNoteData;
}
/**
* Get the regions that were affected by this deletion
*/
public getAffectedRegionIds(): string[] {
return Array.from(new Set(this.deletedNoteData.map(data => data.regionId)));
}
}
/**
* Command to delete a single MIDI note (convenience wrapper)
*/
export class DeleteNoteCommand extends DeleteNotesCommand {
constructor(noteId: string) {
super([noteId]);
}
getDescription(): string {
if (this.getDeletedNoteData().length === 1) {
const note = this.getDeletedNoteData()[0].note;
// Convert MIDI pitch to note name for user-friendly description
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(note.getPitch() / 12) - 1;
const noteName = noteNames[note.getPitch() % 12];
return `Delete note ${noteName}${octave}`;
}
return 'Delete note';
}
}
+253
View File
@@ -0,0 +1,253 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
/**
* Interface for storing move data for each note
*/
interface NoteMoveData {
noteId: string;
originalStartBeat: number;
originalEndBeat: number;
originalPitch: number;
newStartBeat: number;
newEndBeat: number;
newPitch: number;
}
/**
* Command to move multiple notes simultaneously
* Handles moving notes in both time (beat position) and pitch with delta application
*/
export class MoveNotesCommand extends KGCommand {
private primaryNoteId: string;
private startBeatDelta: number;
private pitchDelta: number;
private regionId: string;
private noteIdsToMove: string[]; // Store the specific note IDs to move
private noteMoveData: NoteMoveData[] = [];
private targetRegion: KGMidiRegion | null = null;
private parentTrack: KGTrack | null = null;
constructor(
primaryNoteId: string,
startBeatDelta: number,
pitchDelta: number,
regionId: string,
noteIdsToMove: string[]
) {
super();
this.primaryNoteId = primaryNoteId;
this.startBeatDelta = startBeatDelta;
this.pitchDelta = pitchDelta;
this.regionId = regionId;
this.noteIdsToMove = [...noteIdsToMove]; // Create a copy to avoid reference issues
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the region containing the notes
let targetRegion: KGMidiRegion | null = null;
let parentTrack: KGTrack | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
if (region) {
targetRegion = region;
parentTrack = track;
break;
}
}
if (!targetRegion || !parentTrack) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
this.targetRegion = targetRegion;
this.parentTrack = parentTrack;
// Clear any existing move data (for re-execution)
this.noteMoveData = [];
// Find all notes to move based on the stored note IDs
const notesToMove = this.noteIdsToMove
.map(noteId => targetRegion.getNotes().find(n => n.getId() === noteId))
.filter(note => note !== undefined) as KGMidiNote[];
if (notesToMove.length === 0) {
throw new Error(`No notes found to move from the provided note IDs`);
}
// Verify the primary note is included
const primaryNote = notesToMove.find(n => n.getId() === this.primaryNoteId);
if (!primaryNote) {
throw new Error(`Primary note with ID ${this.primaryNoteId} not found in notes to move`);
}
// Store original positions and calculate new positions
notesToMove.forEach(note => {
const originalStartBeat = note.getStartBeat();
const originalEndBeat = note.getEndBeat();
const originalPitch = note.getPitch();
// Apply the deltas to calculate new positions
const newStartBeat = originalStartBeat + this.startBeatDelta;
const newEndBeat = originalEndBeat + this.startBeatDelta; // End beat moves by same amount as start
const newPitch = originalPitch + this.pitchDelta;
// Store move data for undo
this.noteMoveData.push({
noteId: note.getId(),
originalStartBeat,
originalEndBeat,
originalPitch,
newStartBeat,
newEndBeat,
newPitch
});
});
// Apply all the move operations
this.noteMoveData.forEach(data => {
const note = targetRegion.getNotes().find(n => n.getId() === data.noteId);
if (note) {
note.setStartBeat(data.newStartBeat);
note.setEndBeat(data.newEndBeat);
note.setPitch(data.newPitch);
}
});
const beatDescription = this.startBeatDelta !== 0 ? `position: ${this.startBeatDelta > 0 ? '+' : ''}${this.startBeatDelta.toFixed(3)} beats` : '';
const pitchDescription = this.pitchDelta !== 0 ? `pitch: ${this.pitchDelta > 0 ? '+' : ''}${this.pitchDelta} semitones` : '';
const deltaDescription = [beatDescription, pitchDescription].filter(d => d).join(', ');
console.log(`Moved ${this.noteMoveData.length} notes (${deltaDescription}, primary note: ${this.primaryNoteId})`);
}
undo(): void {
if (!this.targetRegion || this.noteMoveData.length === 0) {
throw new Error('Cannot undo: no notes were moved');
}
// Restore all notes to their original positions
this.noteMoveData.forEach(data => {
const note = this.targetRegion!.getNotes().find(n => n.getId() === data.noteId);
if (note) {
note.setStartBeat(data.originalStartBeat);
note.setEndBeat(data.originalEndBeat);
note.setPitch(data.originalPitch);
}
});
console.log(`Restored ${this.noteMoveData.length} notes to their original positions`);
}
getDescription(): string {
const noteCount = this.noteMoveData.length;
// Create a description based on the type of movement
const movements: string[] = [];
if (this.startBeatDelta !== 0) {
const direction = this.startBeatDelta > 0 ? 'right' : 'left';
movements.push(`${direction} ${Math.abs(this.startBeatDelta).toFixed(3)} beats`);
}
if (this.pitchDelta !== 0) {
const direction = this.pitchDelta > 0 ? 'up' : 'down';
movements.push(`${direction} ${Math.abs(this.pitchDelta)} semitones`);
}
const movementDescription = movements.length > 0 ? ` ${movements.join(' and ')}` : '';
if (noteCount === 1) {
return `Move note${movementDescription}`;
} else {
return `Move ${noteCount} notes${movementDescription}`;
}
}
/**
* Get the primary note ID
*/
public getPrimaryNoteId(): string {
return this.primaryNoteId;
}
/**
* Get the start beat delta
*/
public getStartBeatDelta(): number {
return this.startBeatDelta;
}
/**
* Get the pitch delta
*/
public getPitchDelta(): number {
return this.pitchDelta;
}
/**
* Get the region ID
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the note IDs that will be moved
*/
public getNoteIdsToMove(): string[] {
return [...this.noteIdsToMove];
}
/**
* Get the note move data (only available after execute)
*/
public getNoteMoveData(): NoteMoveData[] {
return [...this.noteMoveData];
}
/**
* Get the target region instance (only available after execute)
*/
public getTargetRegion(): KGMidiRegion | null {
return this.targetRegion;
}
/**
* Get the parent track instance (only available after execute)
*/
public getParentTrack(): KGTrack | null {
return this.parentTrack;
}
/**
* Factory method to create a move command from note drag parameters
*/
public static fromNoteDrag(
primaryNoteId: string,
originalStartBeat: number,
originalPitch: number,
newStartBeat: number,
newPitch: number,
regionId: string,
noteIdsToMove: string[]
): MoveNotesCommand {
const startBeatDelta = newStartBeat - originalStartBeat;
const pitchDelta = newPitch - originalPitch;
return new MoveNotesCommand(
primaryNoteId,
startBeatDelta,
pitchDelta,
regionId,
noteIdsToMove
);
}
}
+204
View File
@@ -0,0 +1,204 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
import { generateUniqueId } from '../../../util/miscUtil';
/**
* Interface for storing created note data for undo
*/
interface CreatedNoteData {
noteId: string;
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
}
/**
* Command to paste notes to a target region
* Handles creating new notes with new IDs and maintaining relative positions
*/
export class PasteNotesCommand extends KGCommand {
private regionId: string;
private pastePosition: number;
private sourceNotes: KGMidiNote[];
private createdNotes: CreatedNoteData[] = [];
private targetRegion: KGMidiRegion | null = null;
private parentTrack: KGTrack | null = null;
constructor(regionId: string, pastePosition: number, sourceNotes: KGMidiNote[]) {
super();
this.regionId = regionId;
this.pastePosition = pastePosition;
this.sourceNotes = [...sourceNotes]; // Create a copy to avoid reference issues
}
execute(): void {
if (this.sourceNotes.length === 0) {
throw new Error('No notes to paste');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the target region
let targetRegion: KGMidiRegion | null = null;
let parentTrack: KGTrack | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId);
if (region && region instanceof KGMidiRegion) {
targetRegion = region;
parentTrack = track;
break;
}
}
if (!targetRegion || !parentTrack) {
throw new Error(`Target MIDI region with ID ${this.regionId} not found`);
}
this.targetRegion = targetRegion;
this.parentTrack = parentTrack;
// Clear any previously created notes (for re-execution)
this.createdNotes = [];
// Calculate relative position within the region
const relativeStartPosition = this.pastePosition - targetRegion.getStartFromBeat();
// Calculate the base position from the first note to maintain relative positions
const basePosition = this.sourceNotes[0].getStartBeat();
// Create new notes at the target position
this.sourceNotes.forEach((originalNote) => {
// Generate new ID for the pasted note
const newId = generateUniqueId('KGMidiNote');
// Calculate new position maintaining relative offset
const noteOffset = originalNote.getStartBeat() - basePosition;
const newStartBeat = relativeStartPosition + noteOffset;
const duration = originalNote.getEndBeat() - originalNote.getStartBeat();
const newEndBeat = newStartBeat + duration;
// Create a copy of the note with new position and ID
const newNote = new KGMidiNote(
newId,
newStartBeat,
newEndBeat,
originalNote.getPitch(),
originalNote.getVelocity()
);
// Store created note data for undo
this.createdNotes.push({
noteId: newId,
startBeat: newStartBeat,
endBeat: newEndBeat,
pitch: originalNote.getPitch(),
velocity: originalNote.getVelocity()
});
// Add the new note to the target region
targetRegion.addNote(newNote);
});
console.log(`Pasted ${this.sourceNotes.length} notes to region ${this.regionId} at position ${this.pastePosition}`);
console.log(`Region now has ${targetRegion.getNotes().length} notes total`);
}
undo(): void {
if (!this.targetRegion || this.createdNotes.length === 0) {
throw new Error('Cannot undo: no notes were pasted');
}
// Remove all created notes from the target region
const currentNotes = this.targetRegion.getNotes();
const noteIdsToRemove = new Set(this.createdNotes.map(data => data.noteId));
const filteredNotes = currentNotes.filter(note => !noteIdsToRemove.has(note.getId()));
this.targetRegion.setNotes(filteredNotes);
console.log(`Removed ${this.createdNotes.length} pasted notes from region ${this.regionId}`);
console.log(`Region now has ${this.targetRegion.getNotes().length} notes total`);
}
getDescription(): string {
const noteCount = this.sourceNotes.length;
const regionName = this.targetRegion ? this.targetRegion.getName() : `Region ${this.regionId}`;
if (noteCount === 1) {
return `Paste note to "${regionName}"`;
} else {
return `Paste ${noteCount} notes to "${regionName}"`;
}
}
/**
* Get the target region ID
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the paste position
*/
public getPastePosition(): number {
return this.pastePosition;
}
/**
* Get the source notes being pasted
*/
public getSourceNotes(): KGMidiNote[] {
return [...this.sourceNotes];
}
/**
* Get the created note data (only available after execute)
*/
public getCreatedNotes(): CreatedNoteData[] {
return [...this.createdNotes];
}
/**
* Get the target region instance (only available after execute)
*/
public getTargetRegion(): KGMidiRegion | null {
return this.targetRegion;
}
/**
* Get the parent track instance (only available after execute)
*/
public getParentTrack(): KGTrack | null {
return this.parentTrack;
}
/**
* Factory method to create a paste command from the clipboard
*/
public static fromClipboard(regionId: string, pastePosition: number): PasteNotesCommand | null {
const core = KGCore.instance();
const copiedItems = core.getCopiedItems();
const notesToCreate = copiedItems.filter(item => item instanceof KGMidiNote) as KGMidiNote[];
if (notesToCreate.length === 0) {
return null;
}
return new PasteNotesCommand(regionId, pastePosition, notesToCreate);
}
/**
* Factory method to create a paste command from specific notes
*/
public static fromNotes(regionId: string, pastePosition: number, notes: KGMidiNote[]): PasteNotesCommand {
return new PasteNotesCommand(regionId, pastePosition, notes);
}
}
@@ -0,0 +1,240 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
/**
* Interface for storing resize data for each note
*/
interface NoteResizeData {
noteId: string;
originalStartBeat: number;
originalEndBeat: number;
newStartBeat: number;
newEndBeat: number;
}
/**
* Command to resize multiple notes simultaneously
* Handles resizing notes from either start or end edge with delta application
*/
export class ResizeNotesCommand extends KGCommand {
private primaryNoteId: string;
private resizeEdge: 'start' | 'end';
private primaryStartBeatDelta: number;
private primaryEndBeatDelta: number;
private regionId: string;
private noteIdsToResize: string[]; // Store the specific note IDs to resize
private noteResizeData: NoteResizeData[] = [];
private targetRegion: KGMidiRegion | null = null;
private parentTrack: KGTrack | null = null;
constructor(
primaryNoteId: string,
resizeEdge: 'start' | 'end',
primaryStartBeatDelta: number,
primaryEndBeatDelta: number,
regionId: string,
noteIdsToResize: string[]
) {
super();
this.primaryNoteId = primaryNoteId;
this.resizeEdge = resizeEdge;
this.primaryStartBeatDelta = primaryStartBeatDelta;
this.primaryEndBeatDelta = primaryEndBeatDelta;
this.regionId = regionId;
this.noteIdsToResize = [...noteIdsToResize]; // Create a copy to avoid reference issues
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the region containing the notes
let targetRegion: KGMidiRegion | null = null;
let parentTrack: KGTrack | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
if (region) {
targetRegion = region;
parentTrack = track;
break;
}
}
if (!targetRegion || !parentTrack) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
this.targetRegion = targetRegion;
this.parentTrack = parentTrack;
// Clear any existing resize data (for re-execution)
this.noteResizeData = [];
// Find all notes to resize based on the stored note IDs
const notesToResize = this.noteIdsToResize
.map(noteId => targetRegion.getNotes().find(n => n.getId() === noteId))
.filter(note => note !== undefined) as KGMidiNote[];
if (notesToResize.length === 0) {
throw new Error(`No notes found to resize from the provided note IDs`);
}
// Verify the primary note is included
const primaryNote = notesToResize.find(n => n.getId() === this.primaryNoteId);
if (!primaryNote) {
throw new Error(`Primary note with ID ${this.primaryNoteId} not found in notes to resize`);
}
// Store original positions and calculate new positions
notesToResize.forEach(note => {
const originalStartBeat = note.getStartBeat();
const originalEndBeat = note.getEndBeat();
let newStartBeat = originalStartBeat;
let newEndBeat = originalEndBeat;
if (note.getId() === this.primaryNoteId) {
// Primary note: apply the exact deltas
newStartBeat = originalStartBeat + this.primaryStartBeatDelta;
newEndBeat = originalEndBeat + this.primaryEndBeatDelta;
} else {
// Other selected notes: apply the same delta as primary note
if (this.resizeEdge === 'start') {
newStartBeat = originalStartBeat + this.primaryStartBeatDelta;
// End beat stays the same for start resize
} else if (this.resizeEdge === 'end') {
newEndBeat = originalEndBeat + this.primaryEndBeatDelta;
// Start beat stays the same for end resize
}
}
// Store resize data for undo
this.noteResizeData.push({
noteId: note.getId(),
originalStartBeat,
originalEndBeat,
newStartBeat,
newEndBeat
});
});
// Apply all the resize operations
this.noteResizeData.forEach(data => {
const note = targetRegion.getNotes().find(n => n.getId() === data.noteId);
if (note) {
note.setStartBeat(data.newStartBeat);
note.setEndBeat(data.newEndBeat);
}
});
console.log(`Resized ${this.noteResizeData.length} notes (edge: ${this.resizeEdge}, primary note: ${this.primaryNoteId})`);
}
undo(): void {
if (!this.targetRegion || this.noteResizeData.length === 0) {
throw new Error('Cannot undo: no notes were resized');
}
// Restore all notes to their original positions
this.noteResizeData.forEach(data => {
const note = this.targetRegion!.getNotes().find(n => n.getId() === data.noteId);
if (note) {
note.setStartBeat(data.originalStartBeat);
note.setEndBeat(data.originalEndBeat);
}
});
console.log(`Restored ${this.noteResizeData.length} notes to their original positions`);
}
getDescription(): string {
const noteCount = this.noteResizeData.length;
const edgeText = this.resizeEdge === 'start' ? 'start' : 'end';
if (noteCount === 1) {
return `Resize note from ${edgeText}`;
} else {
return `Resize ${noteCount} notes from ${edgeText}`;
}
}
/**
* Get the primary note ID
*/
public getPrimaryNoteId(): string {
return this.primaryNoteId;
}
/**
* Get the resize edge
*/
public getResizeEdge(): 'start' | 'end' {
return this.resizeEdge;
}
/**
* Get the region ID
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the note resize data (only available after execute)
*/
public getNoteResizeData(): NoteResizeData[] {
return [...this.noteResizeData];
}
/**
* Get the target region instance (only available after execute)
*/
public getTargetRegion(): KGMidiRegion | null {
return this.targetRegion;
}
/**
* Get the parent track instance (only available after execute)
*/
public getParentTrack(): KGTrack | null {
return this.parentTrack;
}
/**
* Factory method to create a resize command from note resize parameters
*/
public static fromNoteResize(
primaryNoteId: string,
resizeEdge: 'start' | 'end',
originalStartBeat: number,
originalEndBeat: number,
newStartBeat: number,
newEndBeat: number,
regionId: string,
noteIdsToResize: string[]
): ResizeNotesCommand {
const startBeatDelta = newStartBeat - originalStartBeat;
const endBeatDelta = newEndBeat - originalEndBeat;
return new ResizeNotesCommand(
primaryNoteId,
resizeEdge,
startBeatDelta,
endBeatDelta,
regionId,
noteIdsToResize
);
}
/**
* Get the note IDs that will be resized
*/
public getNoteIdsToResize(): string[] {
return [...this.noteIdsToResize];
}
}
@@ -0,0 +1,211 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGProject, type KeySignature } from '../../KGProject';
import type { TimeSignature } from '../../../types/projectTypes';
/**
* Interface defining properties that can be updated on a project
*/
export interface ProjectUpdateProperties {
name?: string;
maxBars?: number;
currentBars?: number;
bpm?: number;
timeSignature?: TimeSignature;
keySignature?: KeySignature;
}
/**
* Command to update project properties
* Handles updating project name, BPM, time signature, etc. with undo support
*/
export class ChangeProjectPropertyCommand extends KGCommand {
private newProperties: ProjectUpdateProperties;
private originalProperties: ProjectUpdateProperties = {};
private targetProject: KGProject | null = null;
private changedProperties: Set<keyof ProjectUpdateProperties> = new Set();
constructor(properties: ProjectUpdateProperties) {
super();
this.newProperties = properties;
}
execute(): void {
const core = KGCore.instance();
this.targetProject = core.getCurrentProject();
// Store original properties for undo
this.originalProperties = {
name: this.targetProject.getName(),
maxBars: this.targetProject.getMaxBars(),
currentBars: this.targetProject.getCurrentBars(),
bpm: this.targetProject.getBpm(),
timeSignature: { ...this.targetProject.getTimeSignature() }, // Create a copy
keySignature: this.targetProject.getKeySignature(),
};
// Apply updates and track what actually changes
const updatedProperties: string[] = [];
// Update name
if (this.newProperties.name !== undefined && this.newProperties.name !== this.originalProperties.name) {
this.targetProject.setName(this.newProperties.name);
this.changedProperties.add('name');
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
}
// Update maxBars
if (this.newProperties.maxBars !== undefined && this.newProperties.maxBars !== this.originalProperties.maxBars) {
this.targetProject.setMaxBars(this.newProperties.maxBars);
this.changedProperties.add('maxBars');
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars}${this.newProperties.maxBars}`);
}
// Update currentBars
if (this.newProperties.currentBars !== undefined && this.newProperties.currentBars !== this.originalProperties.currentBars) {
this.targetProject.setCurrentBars(this.newProperties.currentBars);
this.changedProperties.add('currentBars');
updatedProperties.push(`currentBars: ${this.originalProperties.currentBars}${this.newProperties.currentBars}`);
}
// Update BPM
if (this.newProperties.bpm !== undefined && this.newProperties.bpm !== this.originalProperties.bpm) {
this.targetProject.setBpm(this.newProperties.bpm);
this.changedProperties.add('bpm');
updatedProperties.push(`bpm: ${this.originalProperties.bpm}${this.newProperties.bpm}`);
}
// Update time signature
if (this.newProperties.timeSignature !== undefined) {
const originalTS = this.originalProperties.timeSignature!;
const newTS = this.newProperties.timeSignature;
// Compare time signatures
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
this.targetProject.setTimeSignature(newTS);
this.changedProperties.add('timeSignature');
updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator}${newTS.numerator}/${newTS.denominator}`);
}
}
// Update key signature
if (this.newProperties.keySignature !== undefined && this.newProperties.keySignature !== this.originalProperties.keySignature) {
this.targetProject.setKeySignature(this.newProperties.keySignature);
this.changedProperties.add('keySignature');
updatedProperties.push(`keySignature: "${this.originalProperties.keySignature}" → "${this.newProperties.keySignature}"`);
}
if (updatedProperties.length > 0) {
console.log(`Updated project: ${updatedProperties.join(', ')}`);
} else {
console.log('No changes applied to project');
}
}
undo(): void {
if (!this.targetProject) {
throw new Error('Cannot undo: no project was updated');
}
// Only restore properties that were actually changed
const restoredProperties: string[] = [];
// Restore name (only if it was changed)
if (this.changedProperties.has('name') && this.originalProperties.name !== undefined) {
this.targetProject.setName(this.originalProperties.name);
restoredProperties.push(`name: "${this.originalProperties.name}"`);
}
// Restore maxBars (only if it was changed)
if (this.changedProperties.has('maxBars') && this.originalProperties.maxBars !== undefined) {
this.targetProject.setMaxBars(this.originalProperties.maxBars);
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
}
// Restore currentBars (only if it was changed)
if (this.changedProperties.has('currentBars') && this.originalProperties.currentBars !== undefined) {
this.targetProject.setCurrentBars(this.originalProperties.currentBars);
restoredProperties.push(`currentBars: ${this.originalProperties.currentBars}`);
}
// Restore BPM (only if it was changed)
if (this.changedProperties.has('bpm') && this.originalProperties.bpm !== undefined) {
this.targetProject.setBpm(this.originalProperties.bpm);
restoredProperties.push(`bpm: ${this.originalProperties.bpm}`);
}
// Restore time signature (only if it was changed)
if (this.changedProperties.has('timeSignature') && this.originalProperties.timeSignature !== undefined) {
this.targetProject.setTimeSignature(this.originalProperties.timeSignature);
const ts = this.originalProperties.timeSignature;
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
}
// Restore key signature (only if it was changed)
if (this.changedProperties.has('keySignature') && this.originalProperties.keySignature !== undefined) {
this.targetProject.setKeySignature(this.originalProperties.keySignature);
restoredProperties.push(`keySignature: "${this.originalProperties.keySignature}"`);
}
console.log(`Restored project: ${restoredProperties.join(', ')}`);
}
getDescription(): string {
const updatedProps: string[] = [];
if (this.newProperties.name !== undefined) {
updatedProps.push('name');
}
if (this.newProperties.maxBars !== undefined) {
updatedProps.push('maxBars');
}
if (this.newProperties.currentBars !== undefined) {
updatedProps.push('currentBars');
}
if (this.newProperties.bpm !== undefined) {
updatedProps.push('BPM');
}
if (this.newProperties.timeSignature !== undefined) {
updatedProps.push('time signature');
}
if (this.newProperties.keySignature !== undefined) {
updatedProps.push('key signature');
}
if (updatedProps.length === 1) {
return `Change project ${updatedProps[0]}`;
} else if (updatedProps.length > 1) {
return `Change project properties (${updatedProps.join(', ')})`;
}
return `Change project properties`;
}
/**
* Get the new properties being applied
*/
public getNewProperties(): ProjectUpdateProperties {
return this.newProperties;
}
/**
* Get the original properties (only available after execute)
*/
public getOriginalProperties(): ProjectUpdateProperties {
return this.originalProperties;
}
/**
* Get the target project instance (only available after execute)
*/
public getTargetProject(): KGProject | null {
return this.targetProject;
}
/**
* Get the properties that were actually changed (only available after execute)
*/
public getChangedProperties(): Set<keyof ProjectUpdateProperties> {
return new Set(this.changedProperties);
}
}
@@ -0,0 +1,140 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import { useProjectStore } from '../../../stores/projectStore';
/**
* Command to create a new region in a track
* Handles both the core model update and provides undo functionality
*/
export class CreateRegionCommand extends KGCommand {
private trackId: string;
private trackIndex: number;
private regionName: string;
private startBeat: number;
private lengthInBeats: number;
private regionId: string;
private createdRegion: KGMidiRegion | null = null;
constructor(
trackId: string,
trackIndex: number,
startBeat: number,
lengthInBeats: number,
regionName?: string,
regionId?: string
) {
super();
this.trackId = trackId;
this.trackIndex = trackIndex;
this.startBeat = startBeat;
this.lengthInBeats = lengthInBeats;
this.regionId = regionId || generateUniqueId('KGMidiRegion');
// Generate region name if not provided
const core = KGCore.instance();
const tracks = core.getCurrentProject().getTracks();
const track = tracks.find(t => t.getId().toString() === trackId);
this.regionName = regionName || (track ? `${track.getName()} Region` : 'Region');
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the target track
const targetTrack = tracks.find(track => track.getId().toString() === this.trackId);
if (!targetTrack) {
throw new Error(`Track with ID ${this.trackId} not found`);
}
// Create the new MIDI region
this.createdRegion = new KGMidiRegion(
this.regionId,
this.trackId,
this.trackIndex,
this.regionName,
this.startBeat,
this.lengthInBeats
);
// Add the region to the track
targetTrack.addRegion(this.createdRegion);
console.log(`Created region "${this.regionName}" in track ${this.trackId} at beat ${this.startBeat}`);
}
undo(): void {
if (!this.createdRegion) {
throw new Error('Cannot undo: no region was created');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the target track
const targetTrack = tracks.find(track => track.getId().toString() === this.trackId);
if (!targetTrack) {
throw new Error(`Track with ID ${this.trackId} not found during undo`);
}
// Close piano roll if it's open for this region
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
if (showPianoRoll && activeRegionId === this.regionId) {
setShowPianoRoll(false);
setActiveRegionId(null);
console.log(`Closed piano roll because active region ${this.regionId} is being removed`);
}
// Remove the region from the track
targetTrack.removeRegion(this.regionId);
console.log(`Removed region "${this.regionName}" from track ${this.trackId}`);
}
getDescription(): string {
return `Create region "${this.regionName}"`;
}
/**
* Get the ID of the region that was/will be created
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the created region instance (only available after execute)
*/
public getCreatedRegion(): KGMidiRegion | null {
return this.createdRegion;
}
/**
* Create a region command from bar-based coordinates (common UI pattern)
*/
public static fromBarCoordinates(
trackId: string,
trackIndex: number,
barNumber: number,
lengthInBars: number,
beatsPerBar: number,
regionName?: string,
regionId?: string
): CreateRegionCommand {
const startBeat = (barNumber - 1) * beatsPerBar;
const lengthInBeats = lengthInBars * beatsPerBar;
return new CreateRegionCommand(
trackId,
trackIndex,
startBeat,
lengthInBeats,
regionName,
regionId
);
}
}
@@ -0,0 +1,257 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGTrack } from '../../track/KGTrack';
import { useProjectStore } from '../../../stores/projectStore';
/**
* Command to delete a region from a track
* Handles both the core model update and provides undo functionality
*/
export class DeleteRegionCommand extends KGCommand {
private regionId: string;
private trackId: string | null = null;
private deletedRegion: KGRegion | null = null;
private originalRegionIndex: number = -1;
constructor(regionId: string) {
super();
this.regionId = regionId;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the track that contains this region
let targetTrack: KGTrack | null = null;
let regionToDelete: KGRegion | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const regionIndex = regions.findIndex(region => region.getId() === this.regionId);
if (regionIndex !== -1) {
targetTrack = track;
regionToDelete = regions[regionIndex];
this.originalRegionIndex = regionIndex;
break;
}
}
if (!targetTrack || !regionToDelete) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
// Store data for undo
this.trackId = targetTrack.getId().toString();
this.deletedRegion = regionToDelete;
// Remove the region from the track
targetTrack.removeRegion(this.regionId);
// Clear selection if this region was selected
const selectedItems = core.getSelectedItems();
const selectedRegion = selectedItems.find(item =>
item instanceof KGRegion && item.getId() === this.regionId
);
if (selectedRegion) {
core.removeSelectedItem(selectedRegion);
}
// Close piano roll if it's open for this region
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
if (showPianoRoll && activeRegionId === this.regionId) {
setShowPianoRoll(false);
setActiveRegionId(null);
console.log(`Closed piano roll because active region ${this.regionId} is being deleted`);
}
const regionName = regionToDelete.getName();
console.log(`Deleted region "${regionName}" from track ${this.trackId}`);
}
undo(): void {
if (!this.deletedRegion || !this.trackId) {
throw new Error('Cannot undo: no region data stored');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the target track
const targetTrack = tracks.find(track => track.getId().toString() === this.trackId);
if (!targetTrack) {
throw new Error(`Track with ID ${this.trackId} not found during undo`);
}
// Restore the region to its original position
const regions = targetTrack.getRegions();
if (this.originalRegionIndex >= 0 && this.originalRegionIndex <= regions.length) {
// Insert at the original position
regions.splice(this.originalRegionIndex, 0, this.deletedRegion);
targetTrack.setRegions(regions);
} else {
// Fallback: add to the end
targetTrack.addRegion(this.deletedRegion);
}
const regionName = this.deletedRegion.getName();
console.log(`Restored region "${regionName}" to track ${this.trackId}`);
}
getDescription(): string {
const regionName = this.deletedRegion ? this.deletedRegion.getName() : `Region ${this.regionId}`;
return `Delete region "${regionName}"`;
}
/**
* Get the ID of the region that was/will be deleted
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the deleted region instance (only available after execute)
*/
public getDeletedRegion(): KGRegion | null {
return this.deletedRegion;
}
}
/**
* Command to delete multiple regions at once
* More efficient than individual delete commands for bulk operations
*/
export class DeleteMultipleRegionsCommand extends KGCommand {
private regionIds: string[];
private deletedRegionData: Array<{
region: KGRegion;
trackId: string;
originalIndex: number;
}> = [];
constructor(regionIds: string[]) {
super();
this.regionIds = regionIds;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Clear any existing deleted region data to prevent duplicates on re-execution
this.deletedRegionData = [];
// Find and store all regions to delete
for (const regionId of this.regionIds) {
for (const track of tracks) {
const regions = track.getRegions();
const regionIndex = regions.findIndex(region => region.getId() === regionId);
if (regionIndex !== -1) {
const regionToDelete = regions[regionIndex];
// Store for undo
this.deletedRegionData.push({
region: regionToDelete,
trackId: track.getId().toString(),
originalIndex: regionIndex
});
break;
}
}
}
if (this.deletedRegionData.length === 0) {
throw new Error('No regions found to delete');
}
// Sort by original index in descending order to maintain correct indices during deletion
this.deletedRegionData.sort((a, b) => b.originalIndex - a.originalIndex);
// Check if piano roll should be closed before deleting regions
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
const regionIdsToDelete = new Set(this.deletedRegionData.map(data => data.region.getId()));
if (showPianoRoll && activeRegionId && regionIdsToDelete.has(activeRegionId)) {
setShowPianoRoll(false);
setActiveRegionId(null);
console.log(`Closed piano roll because active region ${activeRegionId} is being deleted`);
}
// Delete all regions
for (const data of this.deletedRegionData) {
const targetTrack = tracks.find(track => track.getId().toString() === data.trackId);
if (targetTrack) {
targetTrack.removeRegion(data.region.getId());
// Clear selection if this region was selected
const selectedItems = core.getSelectedItems();
const selectedRegion = selectedItems.find(item =>
item instanceof KGRegion && item.getId() === data.region.getId()
);
if (selectedRegion) {
core.removeSelectedItem(selectedRegion);
}
}
}
console.log(`Deleted ${this.deletedRegionData.length} regions`);
}
undo(): void {
if (this.deletedRegionData.length === 0) {
throw new Error('Cannot undo: no region data stored');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Restore all regions in reverse order (original index ascending)
const sortedData = [...this.deletedRegionData].sort((a, b) => a.originalIndex - b.originalIndex);
for (const data of sortedData) {
const targetTrack = tracks.find(track => track.getId().toString() === data.trackId);
if (targetTrack) {
const regions = targetTrack.getRegions();
if (data.originalIndex >= 0 && data.originalIndex <= regions.length) {
regions.splice(data.originalIndex, 0, data.region);
targetTrack.setRegions(regions);
} else {
targetTrack.addRegion(data.region);
}
}
}
console.log(`Restored ${this.deletedRegionData.length} regions`);
}
getDescription(): string {
if (this.deletedRegionData.length === 1) {
const regionName = this.deletedRegionData[0].region.getName();
return `Delete region "${regionName}"`;
}
return `Delete ${this.regionIds.length} regions`;
}
/**
* Get the IDs of regions that were/will be deleted
*/
public getRegionIds(): string[] {
return this.regionIds;
}
/**
* Get the deleted region data (only available after execute)
*/
public getDeletedRegionData(): Array<{region: KGRegion; trackId: string; originalIndex: number}> {
return this.deletedRegionData;
}
}
@@ -0,0 +1,237 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
/**
* Command to move a region to a new position and/or track
* Handles both position changes within the same track and moves between tracks
*/
export class MoveRegionCommand extends KGCommand {
private regionId: string;
private newStartFromBeat: number;
private newTrackId: string;
private newTrackIndex: number;
// Original state for undo
private originalStartFromBeat: number = 0;
private originalTrackId: string = '';
private originalTrackIndex: number = 0;
private targetRegion: KGRegion | null = null;
private originalTrack: KGTrack | null = null;
private targetTrack: KGTrack | null = null;
constructor(regionId: string, newStartFromBeat: number, newTrackId: string, newTrackIndex: number) {
super();
this.regionId = regionId;
this.newStartFromBeat = newStartFromBeat;
this.newTrackId = newTrackId;
this.newTrackIndex = newTrackIndex;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the region to move
let targetRegion: KGRegion | null = null;
let originalTrack: KGTrack | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId);
if (region) {
targetRegion = region;
originalTrack = track;
break;
}
}
if (!targetRegion || !originalTrack) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
// Find the target track
const targetTrack = tracks.find(t => t.getId().toString() === this.newTrackId);
if (!targetTrack) {
throw new Error(`Target track with ID ${this.newTrackId} not found`);
}
this.targetRegion = targetRegion;
this.originalTrack = originalTrack;
this.targetTrack = targetTrack;
// Store original values for undo
this.originalStartFromBeat = targetRegion.getStartFromBeat();
this.originalTrackId = targetRegion.getTrackId();
this.originalTrackIndex = targetRegion.getTrackIndex();
// Check if we're moving to a different track
const isTrackChange = this.originalTrackId !== this.newTrackId;
if (isTrackChange) {
// Remove from original track
const originalRegions = originalTrack.getRegions();
originalTrack.setRegions(originalRegions.filter(r => r.getId() !== this.regionId));
// Add to target track
const targetRegions = targetTrack.getRegions();
targetTrack.setRegions([...targetRegions, targetRegion]);
console.log(`Moved region "${targetRegion.getName()}" from track ${this.originalTrackIndex} to track ${this.newTrackIndex}`);
}
// Update region properties
targetRegion.setStartFromBeat(this.newStartFromBeat);
targetRegion.setTrackId(this.newTrackId);
targetRegion.setTrackIndex(this.newTrackIndex);
const regionName = targetRegion.getName();
console.log(`Moved region "${regionName}": position ${this.originalStartFromBeat}${this.newStartFromBeat}, track ${this.originalTrackIndex}${this.newTrackIndex}`);
}
undo(): void {
if (!this.targetRegion || !this.originalTrack || !this.targetTrack) {
throw new Error('Cannot undo: region or tracks not found');
}
// Check if we moved between tracks
const wasTrackChange = this.originalTrackId !== this.newTrackId;
if (wasTrackChange) {
// Remove from current track
const currentRegions = this.targetTrack.getRegions();
this.targetTrack.setRegions(currentRegions.filter(r => r.getId() !== this.regionId));
// Add back to original track
const originalRegions = this.originalTrack.getRegions();
this.originalTrack.setRegions([...originalRegions, this.targetRegion]);
console.log(`Restored region "${this.targetRegion.getName()}" back to original track ${this.originalTrackIndex}`);
}
// Restore original region properties
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
this.targetRegion.setTrackId(this.originalTrackId);
this.targetRegion.setTrackIndex(this.originalTrackIndex);
const regionName = this.targetRegion.getName();
console.log(`Restored region "${regionName}": position ${this.newStartFromBeat}${this.originalStartFromBeat}, track ${this.newTrackIndex}${this.originalTrackIndex}`);
}
getDescription(): string {
const regionName = this.targetRegion ? this.targetRegion.getName() : `Region ${this.regionId}`;
// Check if it's a track change or position change
const isTrackChange = this.originalTrackId !== this.newTrackId;
const isPositionChange = this.originalStartFromBeat !== this.newStartFromBeat;
if (isTrackChange && isPositionChange) {
return `Move region "${regionName}" to different track and position`;
} else if (isTrackChange) {
return `Move region "${regionName}" to different track`;
} else if (isPositionChange) {
return `Move region "${regionName}" to new position`;
} else {
return `Move region "${regionName}"`;
}
}
/**
* Get the ID of the region being moved
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the new start position
*/
public getNewStartFromBeat(): number {
return this.newStartFromBeat;
}
/**
* Get the new track ID
*/
public getNewTrackId(): string {
return this.newTrackId;
}
/**
* Get the new track index
*/
public getNewTrackIndex(): number {
return this.newTrackIndex;
}
/**
* Get the original start position (only available after execute)
*/
public getOriginalStartFromBeat(): number {
return this.originalStartFromBeat;
}
/**
* Get the original track ID (only available after execute)
*/
public getOriginalTrackId(): string {
return this.originalTrackId;
}
/**
* Get the original track index (only available after execute)
*/
public getOriginalTrackIndex(): number {
return this.originalTrackIndex;
}
/**
* Get the target region instance (only available after execute)
*/
public getTargetRegion(): KGRegion | null {
return this.targetRegion;
}
/**
* Factory method to create a move command from bar-based coordinates
*/
public static fromBarCoordinates(
regionId: string,
newBarNumber: number,
newTrackId: string,
newTrackIndex: number,
timeSignature: { numerator: number; denominator: number }
): MoveRegionCommand {
const beatsPerBar = timeSignature.numerator;
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
return new MoveRegionCommand(regionId, newStartFromBeat, newTrackId, newTrackIndex);
}
/**
* Factory method to create a position-only move command (same track)
*/
public static createPositionOnlyMove(
regionId: string,
newStartFromBeat: number,
currentTrackId: string,
currentTrackIndex: number
): MoveRegionCommand {
return new MoveRegionCommand(regionId, newStartFromBeat, currentTrackId, currentTrackIndex);
}
/**
* Factory method to create a track-only move command (same position)
*/
public static createTrackOnlyMove(
regionId: string,
currentStartFromBeat: number,
newTrackId: string,
newTrackIndex: number
): MoveRegionCommand {
return new MoveRegionCommand(regionId, currentStartFromBeat, newTrackId, newTrackIndex);
}
}
@@ -0,0 +1,206 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGTrack } from '../../track/KGTrack';
import { generateUniqueId } from '../../../util/miscUtil';
import { useProjectStore } from '../../../stores/projectStore';
/**
* Command to paste regions with their notes to a target track at a specific position
* Handles creating new regions with new IDs and copying all notes with proper positioning
*/
export class PasteRegionsCommand extends KGCommand {
private targetTrackId: string;
private pastePosition: number;
private sourceRegions: KGRegion[];
private createdRegions: KGRegion[] = [];
private targetTrack: KGTrack | null = null;
constructor(targetTrackId: string, pastePosition: number, sourceRegions: KGRegion[]) {
super();
this.targetTrackId = targetTrackId;
this.pastePosition = pastePosition;
this.sourceRegions = [...sourceRegions]; // Create a copy to avoid reference issues
}
execute(): void {
if (this.sourceRegions.length === 0) {
throw new Error('No regions to paste');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the target track
const targetTrack = tracks.find(track => track.getId().toString() === this.targetTrackId);
if (!targetTrack) {
throw new Error(`Target track with ID ${this.targetTrackId} not found`);
}
this.targetTrack = targetTrack;
// Clear any previously created regions (for re-execution)
this.createdRegions = [];
// Calculate the base position from the first region to maintain relative positions
const basePosition = this.sourceRegions[0].getStartFromBeat();
// Create new regions at the target position
this.sourceRegions.forEach((originalRegion) => {
// Generate new ID for the pasted region
const newId = generateUniqueId('KGMidiRegion');
// Calculate new position maintaining relative offset
const relativeOffset = originalRegion.getStartFromBeat() - basePosition;
const newPosition = this.pastePosition + relativeOffset;
// Create a copy of the region with new position and ID
let newRegion: KGRegion;
if (originalRegion instanceof KGMidiRegion) {
newRegion = new KGMidiRegion(
newId,
targetTrack.getId().toString(),
targetTrack.getTrackIndex(),
`${originalRegion.getName()} (Copy)`,
newPosition,
originalRegion.getLength()
);
// Copy all notes from the original region
const originalNotes = originalRegion.getNotes();
originalNotes.forEach(note => {
const copiedNote = new KGMidiNote(
generateUniqueId('KGMidiNote'),
note.getStartBeat(),
note.getEndBeat(),
note.getPitch(),
note.getVelocity()
);
(newRegion as KGMidiRegion).addNote(copiedNote);
});
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
} else {
// Fallback for other region types
newRegion = new KGRegion(
newId,
targetTrack.getId().toString(),
targetTrack.getTrackIndex(),
`${originalRegion.getName()} (Copy)`,
newPosition,
originalRegion.getLength()
);
console.log(`Created region "${newRegion.getName()}"`);
}
// Add the new region to the target track
const currentRegions = targetTrack.getRegions();
targetTrack.setRegions([...currentRegions, newRegion]);
// Track the created region for undo
this.createdRegions.push(newRegion);
});
console.log(`Pasted ${this.sourceRegions.length} regions to track ${this.targetTrackId} at position ${this.pastePosition}`);
}
undo(): void {
if (!this.targetTrack || this.createdRegions.length === 0) {
throw new Error('Cannot undo: no regions were pasted');
}
// Check if piano roll is open for any of the regions we're about to remove
const regionIdsToRemove = new Set(this.createdRegions.map(r => r.getId()));
// Get current active region from the store
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
// Close piano roll if it's open for one of the regions we're removing
if (showPianoRoll && activeRegionId && regionIdsToRemove.has(activeRegionId)) {
setShowPianoRoll(false);
setActiveRegionId(null);
console.log(`Closed piano roll because active region ${activeRegionId} is being removed`);
}
// Remove all created regions from the target track
const currentRegions = this.targetTrack.getRegions();
const filteredRegions = currentRegions.filter(region => !regionIdsToRemove.has(region.getId()));
this.targetTrack.setRegions(filteredRegions);
console.log(`Removed ${this.createdRegions.length} pasted regions from track ${this.targetTrackId}`);
}
getDescription(): string {
const regionCount = this.sourceRegions.length;
const trackName = this.targetTrack ? this.targetTrack.getName() : `Track ${this.targetTrackId}`;
if (regionCount === 1) {
const regionName = this.sourceRegions[0].getName();
return `Paste region "${regionName}" to "${trackName}"`;
} else {
return `Paste ${regionCount} regions to "${trackName}"`;
}
}
/**
* Get the target track ID
*/
public getTargetTrackId(): string {
return this.targetTrackId;
}
/**
* Get the paste position
*/
public getPastePosition(): number {
return this.pastePosition;
}
/**
* Get the source regions being pasted
*/
public getSourceRegions(): KGRegion[] {
return [...this.sourceRegions];
}
/**
* Get the created regions (only available after execute)
*/
public getCreatedRegions(): KGRegion[] {
return [...this.createdRegions];
}
/**
* Get the target track instance (only available after execute)
*/
public getTargetTrack(): KGTrack | null {
return this.targetTrack;
}
/**
* Factory method to create a paste command from the clipboard
*/
public static fromClipboard(targetTrackId: string, pastePosition: number): PasteRegionsCommand | null {
const core = KGCore.instance();
const copiedItems = core.getCopiedItems();
const regionsToCreate = copiedItems.filter(item => item instanceof KGRegion) as KGRegion[];
if (regionsToCreate.length === 0) {
return null;
}
return new PasteRegionsCommand(targetTrackId, pastePosition, regionsToCreate);
}
/**
* Factory method to create a paste command from specific regions
*/
public static fromRegions(targetTrackId: string, pastePosition: number, regions: KGRegion[]): PasteRegionsCommand {
return new PasteRegionsCommand(targetTrackId, pastePosition, regions);
}
}
@@ -0,0 +1,195 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
/**
* Command to resize a region (change start position and/or length)
* Handles both the core model update and note adjustments for start position changes
*/
export class ResizeRegionCommand extends KGCommand {
private regionId: string;
private newStartFromBeat: number;
private newLength: number;
private originalStartFromBeat: number = 0;
private originalLength: number = 0;
private targetRegion: KGRegion | null = null;
// Store note adjustments for undo
private noteAdjustments: Array<{
noteId: string;
originalStartBeat: number;
originalEndBeat: number;
}> = [];
constructor(regionId: string, newStartFromBeat: number, newLength: number) {
super();
this.regionId = regionId;
this.newStartFromBeat = newStartFromBeat;
this.newLength = newLength;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the region to resize
let targetRegion: KGRegion | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId);
if (region) {
targetRegion = region;
break;
}
}
if (!targetRegion) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
this.targetRegion = targetRegion;
// Store original values for undo
this.originalStartFromBeat = targetRegion.getStartFromBeat();
this.originalLength = targetRegion.getLength();
// Handle note adjustments if start position changes (left-edge resize)
if (this.newStartFromBeat !== this.originalStartFromBeat && targetRegion instanceof KGMidiRegion) {
const beatOffset = this.newStartFromBeat - this.originalStartFromBeat;
// Store original note positions and adjust notes to maintain absolute positions
const notes = targetRegion.getNotes();
notes.forEach(note => {
// Store original positions for undo
this.noteAdjustments.push({
noteId: note.getId(),
originalStartBeat: note.getStartBeat(),
originalEndBeat: note.getEndBeat()
});
// Adjust note positions to maintain absolute position
note.setStartBeat(note.getStartBeat() - beatOffset);
note.setEndBeat(note.getEndBeat() - beatOffset);
});
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
}
// Apply the resize
targetRegion.setStartFromBeat(this.newStartFromBeat);
targetRegion.setLength(this.newLength);
const regionName = targetRegion.getName();
console.log(`Resized region "${regionName}": start ${this.originalStartFromBeat}${this.newStartFromBeat}, length ${this.originalLength}${this.newLength}`);
}
undo(): void {
if (!this.targetRegion) {
throw new Error('Cannot undo: no region was resized');
}
// Restore note positions if they were adjusted
if (this.noteAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
const notes = this.targetRegion.getNotes();
// Restore each note to its original position
this.noteAdjustments.forEach(adjustment => {
const note = notes.find(n => n.getId() === adjustment.noteId);
if (note) {
note.setStartBeat(adjustment.originalStartBeat);
note.setEndBeat(adjustment.originalEndBeat);
}
});
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
}
// Restore original region values
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
this.targetRegion.setLength(this.originalLength);
const regionName = this.targetRegion.getName();
console.log(`Restored region "${regionName}": start ${this.newStartFromBeat}${this.originalStartFromBeat}, length ${this.newLength}${this.originalLength}`);
}
getDescription(): string {
const regionName = this.targetRegion ? this.targetRegion.getName() : `Region ${this.regionId}`;
return `Resize region "${regionName}"`;
}
/**
* Get the ID of the region being resized
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the new start position
*/
public getNewStartFromBeat(): number {
return this.newStartFromBeat;
}
/**
* Get the new length
*/
public getNewLength(): number {
return this.newLength;
}
/**
* Get the original start position (only available after execute)
*/
public getOriginalStartFromBeat(): number {
return this.originalStartFromBeat;
}
/**
* Get the original length (only available after execute)
*/
public getOriginalLength(): number {
return this.originalLength;
}
/**
* Get the target region instance (only available after execute)
*/
public getTargetRegion(): KGRegion | null {
return this.targetRegion;
}
/**
* Factory method to create a resize command for position only (move without length change)
*/
public static createMoveCommand(regionId: string, newStartFromBeat: number, currentLength: number): ResizeRegionCommand {
return new ResizeRegionCommand(regionId, newStartFromBeat, currentLength);
}
/**
* Factory method to create a resize command for length only (resize without position change)
*/
public static createLengthChangeCommand(regionId: string, currentStartFromBeat: number, newLength: number): ResizeRegionCommand {
return new ResizeRegionCommand(regionId, currentStartFromBeat, newLength);
}
/**
* Factory method to create a resize command from bar-based coordinates
*/
public static fromBarCoordinates(
regionId: string,
newBarNumber: number,
newLengthInBars: number,
timeSignature: { numerator: number; denominator: number }
): ResizeRegionCommand {
const beatsPerBar = timeSignature.numerator;
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
const newLength = newLengthInBars * beatsPerBar;
return new ResizeRegionCommand(regionId, newStartFromBeat, newLength);
}
}
@@ -0,0 +1,155 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGTrack } from '../../track/KGTrack';
/**
* Interface defining properties that can be updated on a region
*/
export interface RegionUpdateProperties {
name?: string;
// Future properties can be added here (e.g., color, instrument, etc.)
}
/**
* Command to update region properties
* Handles updating region name and other properties with undo support
*/
export class UpdateRegionCommand extends KGCommand {
private regionId: string;
private newProperties: RegionUpdateProperties;
private originalProperties: RegionUpdateProperties = {};
private targetRegion: KGRegion | null = null;
private parentTrack: KGTrack | null = null;
private changedProperties: Set<keyof RegionUpdateProperties> = new Set();
constructor(regionId: string, properties: RegionUpdateProperties) {
super();
this.regionId = regionId;
this.newProperties = properties;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the region across all tracks
let targetRegion: KGRegion | null = null;
let parentTrack: KGTrack | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === this.regionId);
if (region) {
targetRegion = region;
parentTrack = track;
break;
}
}
if (!targetRegion || !parentTrack) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
this.targetRegion = targetRegion;
this.parentTrack = parentTrack;
// Store original properties for undo
this.originalProperties = {
name: this.targetRegion.getName(),
};
// Apply updates and track what actually changes
const updatedProperties: string[] = [];
// Update name
if (this.newProperties.name !== undefined && this.newProperties.name !== this.originalProperties.name) {
this.targetRegion.setName(this.newProperties.name);
this.changedProperties.add('name');
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
}
if (updatedProperties.length > 0) {
console.log(`Updated region ${this.regionId}: ${updatedProperties.join(', ')}`);
} else {
console.log(`No changes applied to region ${this.regionId}`);
}
}
undo(): void {
if (!this.targetRegion) {
throw new Error('Cannot undo: no region was updated');
}
// Only restore properties that were actually changed
const restoredProperties: string[] = [];
// Restore name (only if it was changed)
if (this.changedProperties.has('name') && this.originalProperties.name !== undefined) {
this.targetRegion.setName(this.originalProperties.name);
restoredProperties.push(`name: "${this.originalProperties.name}"`);
}
console.log(`Restored region ${this.regionId}: ${restoredProperties.join(', ')}`);
}
getDescription(): string {
const regionName = this.originalProperties.name || `Region ${this.regionId}`;
const updatedProps: string[] = [];
if (this.newProperties.name !== undefined) {
updatedProps.push('name');
}
if (updatedProps.length === 1) {
return `Update region "${regionName}" ${updatedProps[0]}`;
} else if (updatedProps.length > 1) {
return `Update region "${regionName}" properties`;
}
return `Update region "${regionName}"`;
}
/**
* Get the ID of the region being updated
*/
public getRegionId(): string {
return this.regionId;
}
/**
* Get the new properties being applied
*/
public getNewProperties(): RegionUpdateProperties {
return this.newProperties;
}
/**
* Get the original properties (only available after execute)
*/
public getOriginalProperties(): RegionUpdateProperties {
return this.originalProperties;
}
/**
* Get the target region instance (only available after execute)
*/
public getTargetRegion(): KGRegion | null {
return this.targetRegion;
}
/**
* Get the parent track of the region (only available after execute)
*/
public getParentTrack(): KGTrack | null {
return this.parentTrack;
}
/**
* Get the properties that were actually changed (only available after execute)
*/
public getChangedProperties(): Set<keyof RegionUpdateProperties> {
return new Set(this.changedProperties);
}
}
@@ -0,0 +1,99 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiTrack, type InstrumentType } from '../../track/KGMidiTrack';
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
/**
* Command to add a new track to the project
* Handles both the core model update and audio interface setup
*/
export class AddTrackCommand extends KGCommand {
private trackId: number;
private trackName: string;
private instrument: InstrumentType;
private trackIndex: number;
private createdTrack: KGMidiTrack | null = null;
constructor(trackId?: number, trackName?: string, instrument: InstrumentType = 'acoustic_grand_piano') {
super();
// If trackId not provided, calculate it
if (trackId === undefined) {
const currentProject = KGCore.instance().getCurrentProject();
const tracks = currentProject.getTracks();
this.trackId = tracks.length > 0
? Math.max(...tracks.map(track => track.getId())) + 1
: 1;
} else {
this.trackId = trackId;
}
this.trackName = trackName || `Track ${this.trackId}`;
this.instrument = instrument;
// Track index will be set during execution
this.trackIndex = 0;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Set the track index to be the last index in the array
this.trackIndex = tracks.length;
// Create a new MIDI track
this.createdTrack = new KGMidiTrack(this.trackName, this.trackId);
this.createdTrack.setTrackIndex(this.trackIndex);
this.createdTrack.setInstrument(this.instrument);
// Update the core model
const updatedTracks = [...tracks, this.createdTrack];
currentProject.setTracks(updatedTracks);
// Create audio synth for the new track (initialize with track volume)
const audioInterface = KGAudioInterface.instance();
audioInterface.createTrackSynth(this.trackId.toString(), this.instrument);
console.log(`Added track ${this.trackId} with ${this.instrument} instrument`);
}
undo(): void {
if (!this.createdTrack) {
throw new Error('Cannot undo: no track was created');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Remove the track from the core model
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
currentProject.setTracks(updatedTracks);
// Remove audio synth for the track
const audioInterface = KGAudioInterface.instance();
audioInterface.removeTrackSynth(this.trackId.toString());
console.log(`Removed track ${this.trackId} and its audio synth`);
}
getDescription(): string {
return `Add track "${this.trackName}"`;
}
/**
* Get the ID of the track that was/will be created
*/
public getTrackId(): number {
return this.trackId;
}
/**
* Get the created track instance (only available after execute)
*/
public getCreatedTrack(): KGMidiTrack | null {
return this.createdTrack;
}
}
@@ -0,0 +1,113 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGTrack } from '../../track/KGTrack';
import { KGMidiTrack, type InstrumentType } from '../../track/KGMidiTrack';
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
/**
* Command to remove a track from the project
* Handles both the core model update and audio interface cleanup
*/
export class RemoveTrackCommand extends KGCommand {
private trackId: number;
private removedTrack: KGTrack | null = null;
private originalTrackIndex: number = 0;
private originalInstrument: InstrumentType = 'acoustic_grand_piano';
constructor(trackId: number) {
super();
this.trackId = trackId;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the track to remove
const trackToRemove = tracks.find(track => track.getId() === this.trackId);
if (!trackToRemove) {
throw new Error(`Track with ID ${this.trackId} not found`);
}
// Store the track data for undo
this.removedTrack = trackToRemove;
this.originalTrackIndex = trackToRemove.getTrackIndex();
// Store instrument if it's a MIDI track
if (trackToRemove.constructor.name === 'KGMidiTrack' && 'getInstrument' in trackToRemove) {
this.originalInstrument = (trackToRemove as KGMidiTrack).getInstrument();
}
// Remove audio synth for the track
const audioInterface = KGAudioInterface.instance();
audioInterface.removeTrackSynth(this.trackId.toString());
// Remove the track from the core model
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
// Update track indices to match new array positions
updatedTracks.forEach((track, index) => {
track.setTrackIndex(index);
});
currentProject.setTracks(updatedTracks);
console.log(`Removed track ${this.trackId} and its audio synth`);
}
undo(): void {
if (!this.removedTrack) {
throw new Error('Cannot undo: no track was removed');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Restore the track to its original position
const updatedTracks = [...tracks];
// Insert the track at the correct position
if (this.originalTrackIndex >= updatedTracks.length) {
// Add to the end
updatedTracks.push(this.removedTrack);
} else {
// Insert at the original position
updatedTracks.splice(this.originalTrackIndex, 0, this.removedTrack);
}
// Update track indices to match array positions
updatedTracks.forEach((track, index) => {
track.setTrackIndex(index);
});
// Update the core model
currentProject.setTracks(updatedTracks);
// Recreate audio synth for the track
const audioInterface = KGAudioInterface.instance();
audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
console.log(`Restored track ${this.trackId} with ${this.originalInstrument} instrument`);
}
getDescription(): string {
const trackName = this.removedTrack ? this.removedTrack.getName() : `Track ${this.trackId}`;
return `Remove track "${trackName}"`;
}
/**
* Get the ID of the track that was/will be removed
*/
public getTrackId(): number {
return this.trackId;
}
/**
* Get the removed track instance (only available after execute)
*/
public getRemovedTrack(): KGTrack | null {
return this.removedTrack;
}
}
@@ -0,0 +1,114 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGTrack } from '../../track/KGTrack';
/**
* Command to reorder tracks in the project
* Handles moving a track from one position to another with undo support
*/
export class ReorderTracksCommand extends KGCommand {
private sourceIndex: number;
private destinationIndex: number;
private originalTrackOrder: KGTrack[] = [];
constructor(sourceIndex: number, destinationIndex: number) {
super();
this.sourceIndex = sourceIndex;
this.destinationIndex = destinationIndex;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Store original track order for undo
this.originalTrackOrder = [...tracks];
// Validate indices
if (this.sourceIndex < 0 || this.sourceIndex >= tracks.length) {
throw new Error(`Invalid source index: ${this.sourceIndex}`);
}
if (this.destinationIndex < 0 || this.destinationIndex >= tracks.length) {
throw new Error(`Invalid destination index: ${this.destinationIndex}`);
}
// If source and destination are the same, do nothing
if (this.sourceIndex === this.destinationIndex) {
console.log('Source and destination indices are the same, no reordering needed');
return;
}
// Create a copy of the tracks array
const updatedTracks = [...tracks];
// Remove the track from the source index
const [movedTrack] = updatedTracks.splice(this.sourceIndex, 1);
// Insert the track at the destination index
updatedTracks.splice(this.destinationIndex, 0, movedTrack);
// Update trackIndex for all tracks to match their array indices
updatedTracks.forEach((track, index) => {
track.setTrackIndex(index);
});
// Update the core model
currentProject.setTracks(updatedTracks);
const movedTrackName = movedTrack.getName();
console.log(`Reordered track "${movedTrackName}" from index ${this.sourceIndex} to ${this.destinationIndex}`);
}
undo(): void {
if (this.originalTrackOrder.length === 0) {
throw new Error('Cannot undo: no original track order stored');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
// Restore the original track order
const restoredTracks = [...this.originalTrackOrder];
// Update trackIndex for all tracks to match their array indices
restoredTracks.forEach((track, index) => {
track.setTrackIndex(index);
});
// Update the core model
currentProject.setTracks(restoredTracks);
console.log(`Restored original track order (undoing reorder from ${this.sourceIndex} to ${this.destinationIndex})`);
}
getDescription(): string {
if (this.originalTrackOrder.length > 0) {
const movedTrack = this.originalTrackOrder[this.sourceIndex];
const trackName = movedTrack ? movedTrack.getName() : `Track ${this.sourceIndex}`;
return `Reorder track "${trackName}" from position ${this.sourceIndex + 1} to ${this.destinationIndex + 1}`;
}
return `Reorder track from position ${this.sourceIndex + 1} to ${this.destinationIndex + 1}`;
}
/**
* Get the source index of the reorder operation
*/
public getSourceIndex(): number {
return this.sourceIndex;
}
/**
* Get the destination index of the reorder operation
*/
public getDestinationIndex(): number {
return this.destinationIndex;
}
/**
* Get the original track order (only available after execute)
*/
public getOriginalTrackOrder(): KGTrack[] {
return this.originalTrackOrder;
}
}
@@ -0,0 +1,222 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGTrack, TrackType } from '../../track/KGTrack';
import { KGMidiTrack, type InstrumentType } from '../../track/KGMidiTrack';
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
/**
* Interface defining properties that can be updated on a track
*/
export interface TrackUpdateProperties {
name?: string;
instrument?: InstrumentType; // Only applies to MIDI tracks
type?: TrackType;
volume?: number;
}
/**
* Command to update track properties
* Handles updating track name, instrument, and type with undo support
*/
export class UpdateTrackCommand extends KGCommand {
private trackId: number;
private newProperties: TrackUpdateProperties;
private originalProperties: TrackUpdateProperties = {};
private targetTrack: KGTrack | null = null;
private changedProperties: Set<keyof TrackUpdateProperties> = new Set();
constructor(trackId: number, properties: TrackUpdateProperties) {
super();
this.trackId = trackId;
this.newProperties = properties;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Find the target track
this.targetTrack = tracks.find(track => track.getId() === this.trackId) || null;
if (!this.targetTrack) {
throw new Error(`Track with ID ${this.trackId} not found`);
}
// Store original properties for undo
this.originalProperties = {
name: this.targetTrack.getName(),
type: this.targetTrack.getType(),
volume: this.targetTrack.getVolume(),
};
// Store original instrument if it's a MIDI track
if (this.targetTrack instanceof KGMidiTrack) {
this.originalProperties.instrument = this.targetTrack.getInstrument();
}
// Apply updates and track what actually changes
const updatedProperties: string[] = [];
// Update name
if (this.newProperties.name !== undefined && this.newProperties.name !== this.originalProperties.name) {
this.targetTrack.setName(this.newProperties.name);
this.changedProperties.add('name');
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
}
// Update type
if (this.newProperties.type !== undefined && this.newProperties.type !== this.originalProperties.type) {
this.targetTrack.setType(this.newProperties.type);
this.changedProperties.add('type');
updatedProperties.push(`type: ${this.originalProperties.type}${this.newProperties.type}`);
}
// Update instrument (only for MIDI tracks)
if (this.newProperties.instrument !== undefined && this.targetTrack instanceof KGMidiTrack) {
const originalInstrument = this.originalProperties.instrument;
if (this.newProperties.instrument !== originalInstrument) {
// Update the track model
this.targetTrack.setInstrument(this.newProperties.instrument);
// Update the audio interface
const audioInterface = KGAudioInterface.instance();
audioInterface.setTrackInstrument(this.trackId.toString(), this.newProperties.instrument);
this.changedProperties.add('instrument');
updatedProperties.push(`instrument: ${originalInstrument}${this.newProperties.instrument}`);
}
}
// Update volume
if (this.newProperties.volume !== undefined && this.newProperties.volume !== this.originalProperties.volume) {
const newVolume = this.newProperties.volume;
const originalVolume = this.originalProperties.volume;
// Update the track model
this.targetTrack.setVolume(newVolume);
// Update the audio interface
const audioInterface = KGAudioInterface.instance();
audioInterface.setTrackVolume(this.trackId.toString(), newVolume);
this.changedProperties.add('volume');
updatedProperties.push(`volume: ${originalVolume}${newVolume}`);
}
if (updatedProperties.length > 0) {
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
} else {
console.log(`No changes applied to track ${this.trackId}`);
}
}
undo(): void {
if (!this.targetTrack) {
throw new Error('Cannot undo: no track was updated');
}
// Only restore properties that were actually changed
const restoredProperties: string[] = [];
// Restore name (only if it was changed)
if (this.changedProperties.has('name') && this.originalProperties.name !== undefined) {
this.targetTrack.setName(this.originalProperties.name);
restoredProperties.push(`name: "${this.originalProperties.name}"`);
}
// Restore type (only if it was changed)
if (this.changedProperties.has('type') && this.originalProperties.type !== undefined) {
this.targetTrack.setType(this.originalProperties.type);
restoredProperties.push(`type: ${this.originalProperties.type}`);
}
// Restore instrument (only if it was changed and track is MIDI)
if (this.changedProperties.has('instrument') &&
this.originalProperties.instrument !== undefined &&
this.targetTrack instanceof KGMidiTrack) {
// Update the track model
this.targetTrack.setInstrument(this.originalProperties.instrument);
// Update the audio interface
const audioInterface = KGAudioInterface.instance();
audioInterface.setTrackInstrument(this.trackId.toString(), this.originalProperties.instrument);
restoredProperties.push(`instrument: ${this.originalProperties.instrument}`);
}
// Restore volume (only if it was changed)
if (this.changedProperties.has('volume') && this.originalProperties.volume !== undefined) {
// Update the track model
this.targetTrack.setVolume(this.originalProperties.volume);
// Update the audio interface
const audioInterface = KGAudioInterface.instance();
audioInterface.setTrackVolume(this.trackId.toString(), this.originalProperties.volume);
restoredProperties.push(`volume: ${this.originalProperties.volume}`);
}
console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`);
}
getDescription(): string {
const trackName = this.originalProperties.name || `Track ${this.trackId}`;
const updatedProps: string[] = [];
if (this.newProperties.name !== undefined) {
updatedProps.push('name');
}
if (this.newProperties.instrument !== undefined) {
updatedProps.push('instrument');
}
if (this.newProperties.type !== undefined) {
updatedProps.push('type');
}
if (this.newProperties.volume !== undefined) {
updatedProps.push('volume');
}
if (updatedProps.length === 1) {
return `Update track "${trackName}" ${updatedProps[0]}`;
} else if (updatedProps.length > 1) {
return `Update track "${trackName}" properties`;
}
return `Update track "${trackName}"`;
}
/**
* Get the ID of the track being updated
*/
public getTrackId(): number {
return this.trackId;
}
/**
* Get the new properties being applied
*/
public getNewProperties(): TrackUpdateProperties {
return this.newProperties;
}
/**
* Get the original properties (only available after execute)
*/
public getOriginalProperties(): TrackUpdateProperties {
return this.originalProperties;
}
/**
* Get the target track instance (only available after execute)
*/
public getTargetTrack(): KGTrack | null {
return this.targetTrack;
}
/**
* Get the properties that were actually changed (only available after execute)
*/
public getChangedProperties(): Set<keyof TrackUpdateProperties> {
return new Set(this.changedProperties);
}
}