feat: implemented global marker track
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import {
|
||||
DEFAULT_MARKER_REGION_NAME,
|
||||
findGlobalTrackByType,
|
||||
findMarkerNeighborBounds,
|
||||
getSongEndBeat,
|
||||
} from '../../../util/globalTrackUtil';
|
||||
|
||||
export class CreateGlobalMarkerRegionCommand extends KGCommand {
|
||||
private readonly startBeat: number;
|
||||
private readonly preferredLength: number;
|
||||
private readonly regionId: string;
|
||||
private readonly initialName: string;
|
||||
private createdRegion: KGMarkerRegion | null = null;
|
||||
private originalRegionIndex = -1;
|
||||
|
||||
constructor(startBeat: number, preferredLength: number, initialName: string = DEFAULT_MARKER_REGION_NAME, regionId?: string) {
|
||||
super();
|
||||
this.startBeat = startBeat;
|
||||
this.preferredLength = preferredLength;
|
||||
this.initialName = initialName;
|
||||
this.regionId = regionId ?? generateUniqueId('KGMarkerRegion');
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
if (!markerTrack) {
|
||||
throw new Error('Marker global track not found');
|
||||
}
|
||||
|
||||
const { maxEndBeat } = findMarkerNeighborBounds(project, null, this.startBeat);
|
||||
const songEndBeat = getSongEndBeat(project);
|
||||
const allowedEndBeat = Math.min(maxEndBeat, songEndBeat);
|
||||
const targetEndBeat = Math.min(this.startBeat + this.preferredLength, allowedEndBeat);
|
||||
const length = Math.max(1, targetEndBeat - this.startBeat);
|
||||
|
||||
this.createdRegion = new KGMarkerRegion(
|
||||
this.regionId,
|
||||
markerTrack.getId(),
|
||||
markerTrack.getTrackIndex(),
|
||||
this.initialName,
|
||||
this.startBeat,
|
||||
length
|
||||
);
|
||||
|
||||
const regions = [...markerTrack.getRegions(), this.createdRegion]
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
this.originalRegionIndex = regions.findIndex(region => region.getId() === this.regionId);
|
||||
markerTrack.setRegions(regions);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdRegion) {
|
||||
throw new Error('Cannot undo: no global marker region was created');
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
if (!markerTrack) {
|
||||
throw new Error('Marker global track not found during undo');
|
||||
}
|
||||
|
||||
markerTrack.removeRegion(this.regionId);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Create marker "${this.initialName}"`;
|
||||
}
|
||||
|
||||
public getCreatedRegion(): KGMarkerRegion | null {
|
||||
return this.createdRegion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
|
||||
|
||||
export class DeleteGlobalRegionCommand extends KGCommand {
|
||||
private readonly regionId: string;
|
||||
private deletedRegion: KGGlobalRegion | null = null;
|
||||
private trackId: string | null = null;
|
||||
private originalIndex = -1;
|
||||
|
||||
constructor(regionId: string) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const result = findGlobalTrackContainingRegion(project, this.regionId);
|
||||
if (!result) {
|
||||
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.deletedRegion = result.region;
|
||||
this.trackId = result.track.getId();
|
||||
this.originalIndex = result.regionIndex;
|
||||
result.track.removeRegion(this.regionId);
|
||||
|
||||
const selectedItem = KGCore.instance().getSelectedItems().find(item => item.getId() === this.regionId);
|
||||
if (selectedItem) {
|
||||
KGCore.instance().removeSelectedItem(selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.deletedRegion || !this.trackId) {
|
||||
throw new Error('Cannot undo: no deleted global region stored');
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getGlobalTracks().find(candidate => candidate.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Global track ${this.trackId} not found during undo`);
|
||||
}
|
||||
|
||||
const regions = [...track.getRegions()];
|
||||
regions.splice(this.originalIndex, 0, this.deletedRegion);
|
||||
track.setRegions(regions);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Delete marker "${this.deletedRegion?.getName() ?? this.regionId}"`;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteMultipleGlobalRegionsCommand extends KGCommand {
|
||||
private readonly regionIds: string[];
|
||||
private deletedRegions: Array<{ region: KGGlobalRegion; trackId: string; originalIndex: number }> = [];
|
||||
|
||||
constructor(regionIds: string[]) {
|
||||
super();
|
||||
this.regionIds = regionIds;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
this.deletedRegions = [];
|
||||
|
||||
for (const regionId of this.regionIds) {
|
||||
const result = findGlobalTrackContainingRegion(project, regionId);
|
||||
if (!result) continue;
|
||||
this.deletedRegions.push({
|
||||
region: result.region,
|
||||
trackId: result.track.getId(),
|
||||
originalIndex: result.regionIndex,
|
||||
});
|
||||
}
|
||||
|
||||
this.deletedRegions
|
||||
.slice()
|
||||
.sort((left, right) => right.originalIndex - left.originalIndex)
|
||||
.forEach(({ region, trackId }) => {
|
||||
const track = project.getGlobalTracks().find(candidate => candidate.getId() === trackId);
|
||||
track?.removeRegion(region.getId());
|
||||
const selectedItem = KGCore.instance().getSelectedItems().find(item => item.getId() === region.getId());
|
||||
if (selectedItem) {
|
||||
KGCore.instance().removeSelectedItem(selectedItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
this.deletedRegions
|
||||
.slice()
|
||||
.sort((left, right) => left.originalIndex - right.originalIndex)
|
||||
.forEach(({ region, trackId, originalIndex }) => {
|
||||
const track = project.getGlobalTracks().find(candidate => candidate.getId() === trackId);
|
||||
if (!track) return;
|
||||
const regions = [...track.getRegions()];
|
||||
regions.splice(originalIndex, 0, region);
|
||||
track.setRegions(regions);
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return this.regionIds.length === 1 ? 'Delete marker' : `Delete ${this.regionIds.length} markers`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject } from '../../KGProject';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
|
||||
import { CreateGlobalMarkerRegionCommand } from './CreateGlobalMarkerRegionCommand';
|
||||
import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
|
||||
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
|
||||
import { DeleteGlobalRegionCommand } from './DeleteGlobalRegionCommand';
|
||||
import { UpdateGlobalRegionTextCommand } from './UpdateGlobalRegionTextCommand';
|
||||
|
||||
describe('global marker region commands', () => {
|
||||
beforeEach(() => {
|
||||
const project = new KGProject('Markers', 8, 0, 120);
|
||||
const mockCore = KGCore.instance() as unknown as {
|
||||
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||
getSelectedItems: ReturnType<typeof vi.fn>;
|
||||
removeSelectedItem?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
mockCore.getCurrentProject.mockReturnValue(project);
|
||||
mockCore.getSelectedItems.mockReturnValue([]);
|
||||
if (!mockCore.removeSelectedItem) {
|
||||
mockCore.removeSelectedItem = vi.fn();
|
||||
} else {
|
||||
mockCore.removeSelectedItem.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
const getMarkerTrack = () => {
|
||||
const markerTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
|
||||
.find(track => track.getType() === GlobalTrackType.Marker);
|
||||
|
||||
if (!markerTrack) {
|
||||
throw new Error('Marker track missing in test setup');
|
||||
}
|
||||
|
||||
return markerTrack;
|
||||
};
|
||||
|
||||
it('creates a marker region clamped to the next marker start', () => {
|
||||
const markerTrack = getMarkerTrack();
|
||||
markerTrack.addRegion(new KGMarkerRegion('existing', markerTrack.getId(), markerTrack.getTrackIndex(), 'Verse', 10, 4));
|
||||
|
||||
const command = new CreateGlobalMarkerRegionCommand(4, 32, 'Intro');
|
||||
command.execute();
|
||||
|
||||
const created = command.getCreatedRegion();
|
||||
expect(created).not.toBeNull();
|
||||
expect(created?.getStartFromBeat()).toBe(4);
|
||||
expect(created?.getLength()).toBe(6);
|
||||
});
|
||||
|
||||
it('moves a marker region with beat snapping and neighbor clamping', () => {
|
||||
const markerTrack = getMarkerTrack();
|
||||
const region = new KGMarkerRegion('middle', markerTrack.getId(), markerTrack.getTrackIndex(), 'Middle', 4, 2);
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('left', markerTrack.getId(), markerTrack.getTrackIndex(), 'Left', 0, 4),
|
||||
region,
|
||||
new KGMarkerRegion('right', markerTrack.getId(), markerTrack.getTrackIndex(), 'Right', 10, 2),
|
||||
]);
|
||||
|
||||
const command = new MoveGlobalRegionCommand('middle', 9);
|
||||
command.execute();
|
||||
|
||||
expect(region.getStartFromBeat()).toBe(8);
|
||||
|
||||
command.undo();
|
||||
expect(region.getStartFromBeat()).toBe(4);
|
||||
});
|
||||
|
||||
it('resizes a marker region with a minimum length of one beat', () => {
|
||||
const markerTrack = getMarkerTrack();
|
||||
const region = new KGMarkerRegion('marker', markerTrack.getId(), markerTrack.getTrackIndex(), 'Marker', 4, 4);
|
||||
markerTrack.setRegions([region]);
|
||||
|
||||
const resizeStartCommand = new ResizeGlobalRegionCommand('marker', 'start', 7);
|
||||
resizeStartCommand.execute();
|
||||
expect(region.getStartFromBeat()).toBe(7);
|
||||
expect(region.getLength()).toBe(1);
|
||||
|
||||
resizeStartCommand.undo();
|
||||
expect(region.getStartFromBeat()).toBe(4);
|
||||
expect(region.getLength()).toBe(4);
|
||||
|
||||
const resizeEndCommand = new ResizeGlobalRegionCommand('marker', 'end', 5);
|
||||
resizeEndCommand.execute();
|
||||
expect(region.getLength()).toBe(1);
|
||||
});
|
||||
|
||||
it('updates text and deletes with undo support', () => {
|
||||
const markerTrack = getMarkerTrack();
|
||||
const region = new KGMarkerRegion('marker', markerTrack.getId(), markerTrack.getTrackIndex(), 'Old', 0, 4);
|
||||
markerTrack.setRegions([region]);
|
||||
|
||||
const renameCommand = new UpdateGlobalRegionTextCommand('marker', 'New');
|
||||
renameCommand.execute();
|
||||
expect(region.getName()).toBe('New');
|
||||
renameCommand.undo();
|
||||
expect(region.getName()).toBe('Old');
|
||||
|
||||
const deleteCommand = new DeleteGlobalRegionCommand('marker');
|
||||
deleteCommand.execute();
|
||||
expect(markerTrack.getRegions()).toHaveLength(0);
|
||||
deleteCommand.undo();
|
||||
expect(markerTrack.getRegions()).toHaveLength(1);
|
||||
expect(markerTrack.getRegions()[0].getId()).toBe('marker');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds } from '../../../util/globalTrackUtil';
|
||||
|
||||
export class MoveGlobalRegionCommand extends KGCommand {
|
||||
private readonly regionId: string;
|
||||
private readonly desiredStartBeat: number;
|
||||
private targetRegion: KGGlobalRegion | null = null;
|
||||
private originalStartBeat = 0;
|
||||
|
||||
constructor(regionId: string, desiredStartBeat: number) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.desiredStartBeat = desiredStartBeat;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const result = findGlobalTrackContainingRegion(project, this.regionId);
|
||||
if (!result) {
|
||||
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = result.region;
|
||||
this.originalStartBeat = result.region.getStartFromBeat();
|
||||
|
||||
if (result.track.getType() !== GlobalTrackType.Marker) {
|
||||
result.region.setStartFromBeat(this.desiredStartBeat);
|
||||
return;
|
||||
}
|
||||
|
||||
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.desiredStartBeat);
|
||||
const maxStartBeat = Math.max(minStartBeat, maxEndBeat - result.region.getLength());
|
||||
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredStartBeat, maxStartBeat));
|
||||
result.region.setStartFromBeat(clampedStartBeat);
|
||||
|
||||
result.track.setRegions([...result.track.getRegions()].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion) {
|
||||
throw new Error('Cannot undo: no global region was moved');
|
||||
}
|
||||
|
||||
this.targetRegion.setStartFromBeat(this.originalStartBeat);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Move global region "${this.targetRegion?.getName() ?? this.regionId}"`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
|
||||
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||
|
||||
export type GlobalRegionResizeEdge = 'start' | 'end';
|
||||
|
||||
export class ResizeGlobalRegionCommand extends KGCommand {
|
||||
private readonly regionId: string;
|
||||
private readonly edge: GlobalRegionResizeEdge;
|
||||
private readonly desiredBeat: number;
|
||||
private targetRegion: KGGlobalRegion | null = null;
|
||||
private originalStartBeat = 0;
|
||||
private originalLength = 0;
|
||||
|
||||
constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBeat: number) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.edge = edge;
|
||||
this.desiredBeat = desiredBeat;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const result = findGlobalTrackContainingRegion(project, this.regionId);
|
||||
if (!result) {
|
||||
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = result.region;
|
||||
this.originalStartBeat = result.region.getStartFromBeat();
|
||||
this.originalLength = result.region.getLength();
|
||||
|
||||
if (result.track.getType() !== GlobalTrackType.Marker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalEndBeat = this.originalStartBeat + this.originalLength;
|
||||
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.originalStartBeat);
|
||||
const songEndBeat = getSongEndBeat(project);
|
||||
const absoluteMaxEndBeat = Math.min(maxEndBeat, songEndBeat);
|
||||
|
||||
if (this.edge === 'start') {
|
||||
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredBeat, originalEndBeat - 1));
|
||||
result.region.setStartFromBeat(clampedStartBeat);
|
||||
result.region.setLength(Math.max(1, originalEndBeat - clampedStartBeat));
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedEndBeat = Math.max(this.originalStartBeat + 1, Math.min(this.desiredBeat, absoluteMaxEndBeat));
|
||||
result.region.setLength(Math.max(1, clampedEndBeat - this.originalStartBeat));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion) {
|
||||
throw new Error('Cannot undo: no global region was resized');
|
||||
}
|
||||
|
||||
this.targetRegion.setStartFromBeat(this.originalStartBeat);
|
||||
this.targetRegion.setLength(this.originalLength);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Resize global region "${this.targetRegion?.getName() ?? this.regionId}"`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
|
||||
|
||||
export class UpdateGlobalRegionTextCommand extends KGCommand {
|
||||
private readonly regionId: string;
|
||||
private readonly nextText: string;
|
||||
private previousText = '';
|
||||
|
||||
constructor(regionId: string, nextText: string) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.nextText = nextText;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
|
||||
if (!result) {
|
||||
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.previousText = result.region.getName();
|
||||
result.region.setName(this.nextText);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
|
||||
if (!result) {
|
||||
throw new Error(`Global region with ID ${this.regionId} not found during undo`);
|
||||
}
|
||||
|
||||
result.region.setName(this.previousText);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Rename marker to "${this.nextText}"`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user