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
+22
View File
@@ -5,6 +5,7 @@ import { ConfigManager } from './config/ConfigManager';
import { KGProjectStorage } from './io/KGProjectStorage';
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
import { KGMidiRegion } from './region/KGMidiRegion';
import { KGAudioRegion } from './region/KGAudioRegion';
import { KGMidiNote } from './midi/KGMidiNote';
import { KGMidiControllerEvent } from './midi/KGMidiControllerEvent';
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
@@ -535,6 +536,7 @@ export class KGCore {
region.getStartFromBeat(),
region.getLength()
);
clonedRegion.setColor(region.getColor());
// Copy all notes within the region
const originalNotes = region.getNotes();
@@ -568,6 +570,25 @@ export class KGCore {
clonedItems.push(clonedRegion);
break;
}
case 'KGAudioRegion': {
const region = item as KGAudioRegion;
const clonedRegion = new KGAudioRegion(
generateUniqueId('KGAudioRegion'),
region.getTrackId(),
region.getTrackIndex(),
region.getName(),
region.getStartFromBeat(),
region.getLength(),
region.getAudioFileId(),
region.getAudioFileName(),
region.getAudioDurationSeconds(),
region.getClipStartOffsetSeconds()
);
clonedRegion.setColor(region.getColor());
clonedItems.push(clonedRegion);
break;
}
case 'KGRegion': {
const region = item as KGRegion;
@@ -579,6 +600,7 @@ export class KGCore {
region.getStartFromBeat(),
region.getLength()
);
clonedRegion.setColor(region.getColor());
clonedItems.push(clonedRegion);
break;
}
@@ -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]}`;
+11
View File
@@ -27,6 +27,9 @@ export class KGRegion implements Selectable {
@Expose()
protected length: number = 0;
@Expose()
protected color?: string;
@Expose()
protected selected: boolean = false;
@@ -66,6 +69,10 @@ export class KGRegion implements Selectable {
return this.length;
}
public getColor(): string | undefined {
return this.color;
}
// Setters
public setId(id: string): void {
this.id = id;
@@ -91,6 +98,10 @@ export class KGRegion implements Selectable {
this.length = length;
}
public setColor(color: string | undefined): void {
this.color = color;
}
// interface methods
public select(): void {
this.selected = true;
+11
View File
@@ -34,6 +34,9 @@ export class KGTrack {
@Expose()
protected type: TrackType;
@Expose()
protected color?: string;
@Expose()
@WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME)
protected volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
@@ -99,6 +102,10 @@ export class KGTrack {
return this.volume;
}
public getColor(): string | undefined {
return this.color;
}
public getMuted(): boolean {
return this.muted;
}
@@ -136,6 +143,10 @@ export class KGTrack {
);
}
public setColor(color: string | undefined): void {
this.color = color;
}
public setMuted(muted: boolean): void {
this.muted = muted;
}