feat: added region color customization feature

This commit is contained in:
Xiaohan-Tian
2026-07-01 22:31:35 -07:00
parent e626e1fc76
commit 4f11fe8e25
31 changed files with 700 additions and 31 deletions
@@ -8,6 +8,7 @@ import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGTrack } from '../../track/KGTrack';
import { generateUniqueId } from '../../../util/miscUtil';
import { useProjectStore } from '../../../stores/projectStore';
import { KGAudioRegion } from '../../region/KGAudioRegion';
/**
* Command to paste regions with their notes to a target track at a specific position
@@ -71,6 +72,7 @@ export class PasteRegionsCommand extends KGCommand {
newPosition,
originalRegion.getLength()
);
newRegion.setColor(originalRegion.getColor());
// Copy all notes from the original region
const originalNotes = originalRegion.getNotes();
@@ -102,6 +104,22 @@ export class PasteRegionsCommand extends KGCommand {
});
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
} else if (originalRegion instanceof KGAudioRegion) {
newRegion = new KGAudioRegion(
generateUniqueId('KGAudioRegion'),
targetTrack.getId().toString(),
targetTrack.getTrackIndex(),
`${originalRegion.getName()} (Copy)`,
newPosition,
originalRegion.getLength(),
originalRegion.getAudioFileId(),
originalRegion.getAudioFileName(),
originalRegion.getAudioDurationSeconds(),
originalRegion.getClipStartOffsetSeconds()
);
newRegion.setColor(originalRegion.getColor());
console.log(`Created audio region "${newRegion.getName()}"`);
} else {
// Fallback for other region types
newRegion = new KGRegion(
@@ -112,6 +130,7 @@ export class PasteRegionsCommand extends KGCommand {
newPosition,
originalRegion.getLength()
);
newRegion.setColor(originalRegion.getColor());
console.log(`Created region "${newRegion.getName()}"`);
}
@@ -93,6 +93,8 @@ export class SplitRegionCommand extends KGCommand {
this.splitAtBeat,
regionLength - splitOffsetBeats
);
region1.setColor(originalRegion.getColor());
region2.setColor(originalRegion.getColor());
for (const note of originalRegion.getNotes()) {
if (note.getStartBeat() < splitOffsetBeats) {
@@ -167,6 +169,7 @@ export class SplitRegionCommand extends KGCommand {
originalRegion.getAudioDurationSeconds(),
originalRegion.getClipStartOffsetSeconds()
);
this.region1.setColor(originalRegion.getColor());
this.region2 = new KGAudioRegion(
generateUniqueId('KGAudioRegion'),
@@ -180,6 +183,7 @@ export class SplitRegionCommand extends KGCommand {
originalRegion.getAudioDurationSeconds(),
originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds
);
this.region2.setColor(originalRegion.getColor());
} else {
throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`);
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { KGTrack } from '../../track/KGTrack';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { UpdateRegionCommand } from './UpdateRegionCommand';
vi.mock('../../KGCore', () => ({
KGCore: {
instance: vi.fn(),
},
}));
describe('UpdateRegionCommand', () => {
let track: KGTrack;
let region: KGMidiRegion;
let project: KGProject;
const mockCore = {
getCurrentProject: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
track = new KGTrack('Track 1', 1);
region = new KGMidiRegion('region-1', '1', 0, 'Verse', 0, 4);
track.setRegions([region]);
project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 11);
mockCore.getCurrentProject.mockReturnValue(project);
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
});
it('updates color and restores it on undo', () => {
const command = new UpdateRegionCommand('region-1', { color: '#3D77C9' });
command.execute();
expect(region.getColor()).toBe('#3D77C9');
expect(command.getChangedProperties()).toEqual(new Set(['color']));
command.undo();
expect(region.getColor()).toBeUndefined();
});
it('clears an existing region color override', () => {
region.setColor('#B43F1D');
const command = new UpdateRegionCommand('region-1', { color: null });
command.execute();
expect(region.getColor()).toBeUndefined();
expect(command.getChangedProperties()).toEqual(new Set(['color']));
});
});
@@ -8,7 +8,7 @@ import { KGTrack } from '../../track/KGTrack';
*/
export interface RegionUpdateProperties {
name?: string;
// Future properties can be added here (e.g., color, instrument, etc.)
color?: string | null;
}
/**
@@ -58,6 +58,7 @@ export class UpdateRegionCommand extends KGCommand {
// Store original properties for undo
this.originalProperties = {
name: this.targetRegion.getName(),
color: this.targetRegion.getColor(),
};
// Apply updates and track what actually changes
@@ -70,6 +71,12 @@ export class UpdateRegionCommand extends KGCommand {
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
}
if ('color' in this.newProperties && this.newProperties.color !== this.originalProperties.color) {
this.targetRegion.setColor(this.newProperties.color ?? undefined);
this.changedProperties.add('color');
updatedProperties.push(`color: ${this.originalProperties.color ?? 'none'}${this.newProperties.color ?? 'none'}`);
}
if (updatedProperties.length > 0) {
console.log(`Updated region ${this.regionId}: ${updatedProperties.join(', ')}`);
} else {
@@ -91,6 +98,11 @@ export class UpdateRegionCommand extends KGCommand {
restoredProperties.push(`name: "${this.originalProperties.name}"`);
}
if (this.changedProperties.has('color')) {
this.targetRegion.setColor(this.originalProperties.color ?? undefined);
restoredProperties.push(`color: ${this.originalProperties.color ?? 'none'}`);
}
console.log(`Restored region ${this.regionId}: ${restoredProperties.join(', ')}`);
}
@@ -101,6 +113,9 @@ export class UpdateRegionCommand extends KGCommand {
if (this.newProperties.name !== undefined) {
updatedProps.push('name');
}
if ('color' in this.newProperties) {
updatedProps.push('color');
}
if (updatedProps.length === 1) {
return `Update region "${regionName}" ${updatedProps[0]}`;
@@ -152,4 +167,4 @@ export class UpdateRegionCommand extends KGCommand {
public getChangedProperties(): Set<keyof RegionUpdateProperties> {
return new Set(this.changedProperties);
}
}
}
@@ -84,4 +84,27 @@ describe('UpdateTrackCommand', () => {
expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled();
expect(command.getChangedProperties()).toEqual(new Set());
});
it('updates color and restores it on undo', () => {
const command = new UpdateTrackCommand(1, { color: '#3C8AC4' });
command.execute();
expect(track.getColor()).toBe('#3C8AC4');
expect(command.getChangedProperties()).toEqual(new Set(['color']));
command.undo();
expect(track.getColor()).toBeUndefined();
});
it('clears an existing color override', () => {
track.setColor('#B43F1D');
const command = new UpdateTrackCommand(1, { color: null });
command.execute();
expect(track.getColor()).toBeUndefined();
expect(command.getChangedProperties()).toEqual(new Set(['color']));
});
});
@@ -14,6 +14,7 @@ export interface TrackUpdateProperties {
volume?: number;
muted?: boolean;
solo?: boolean;
color?: string | null;
}
/**
@@ -51,6 +52,7 @@ export class UpdateTrackCommand extends KGCommand {
volume: this.targetTrack.getVolume(),
muted: this.targetTrack.getMuted(),
solo: this.targetTrack.getSolo(),
color: this.targetTrack.getColor(),
};
// Store original instrument if it's a MIDI track
@@ -133,6 +135,16 @@ export class UpdateTrackCommand extends KGCommand {
updatedProperties.push(`solo: ${originalSolo}${newSolo}`);
}
if ('color' in this.newProperties && this.newProperties.color !== this.originalProperties.color) {
const newColor = this.newProperties.color ?? undefined;
const originalColor = this.originalProperties.color;
this.targetTrack.setColor(newColor);
this.changedProperties.add('color');
updatedProperties.push(`color: ${originalColor ?? 'none'}${newColor}`);
}
if (updatedProperties.length > 0) {
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
} else {
@@ -204,6 +216,11 @@ export class UpdateTrackCommand extends KGCommand {
restoredProperties.push(`solo: ${this.originalProperties.solo}`);
}
if (this.changedProperties.has('color')) {
this.targetTrack.setColor(this.originalProperties.color ?? undefined);
restoredProperties.push(`color: ${this.originalProperties.color ?? 'none'}`);
}
console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`);
}
@@ -229,6 +246,9 @@ export class UpdateTrackCommand extends KGCommand {
if (this.newProperties.solo !== undefined) {
updatedProps.push('solo');
}
if ('color' in this.newProperties) {
updatedProps.push('color');
}
if (updatedProps.length === 1) {
return `Update track "${trackName}" ${updatedProps[0]}`;