feat: implemented global marker track

This commit is contained in:
Xiaohan-Tian
2026-05-23 12:56:43 -07:00
parent 7ca2cb0d05
commit fb3c6cde70
27 changed files with 1566 additions and 38 deletions
@@ -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}"`;
}
}