+ {markerRegions.map(region => {
+ const { startBeat, length } = getRenderedBeatState(region);
+ const isSelected = selectedRegionIds.includes(region.getId());
+ const isEditing = editingRegionId === region.getId();
+ const left = startBeat * beatWidth;
+ const width = Math.max(beatWidth, length * beatWidth);
+
+ return (
+
{
+ event.stopPropagation();
+ onSelectRegion(region.getId(), { shiftKey: false });
+ onBeginEdit(region.getId());
+ }}
+ onMouseMove={(event) => {
+ if (isEditing) {
+ return;
+ }
+
+ if (interactionRef.current?.regionId === region.getId()) {
+ return;
+ }
+
+ const resizeEdge = getResizeEdgeFromMouseEvent(event);
+ setHoverEdges((current) => (
+ current[region.getId()] === resizeEdge
+ ? current
+ : {
+ ...current,
+ [region.getId()]: resizeEdge,
+ }
+ ));
+ }}
+ onMouseLeave={() => {
+ setHoverEdges((current) => {
+ if (!current[region.getId()]) {
+ return current;
+ }
+
+ return {
+ ...current,
+ [region.getId()]: null,
+ };
+ });
+ }}
+ onMouseDown={(event) => {
+ if (event.button !== 0) return;
+ if (isEditing) return;
+ event.preventDefault();
+ event.stopPropagation();
+
+ const resizeEdge = getResizeEdgeFromMouseEvent(event);
+
+ interactionRef.current = {
+ mode: resizeEdge ? 'resize' : 'drag',
+ regionId: region.getId(),
+ initialMouseX: event.clientX,
+ initialStartBeat: region.getStartFromBeat(),
+ initialLength: region.getLength(),
+ resizeEdge,
+ moved: false,
+ };
+ }}
+ >
+ {isEditing ? (
+ onEditingTextChange(event.target.value.replace(/\r?\n/g, ' '))}
+ onBlur={() => onCommitEdit(region.getId())}
+ onMouseDown={(event) => {
+ event.stopPropagation();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ onCommitEdit(region.getId());
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ onCancelEdit();
+ }
+ }}
+ autoFocus
+ />
+ ) : (
+ {region.getName()}
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export default GlobalMarkerLane;
diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts
index 6f7bf12..8d3600a 100644
--- a/src/core/KGProject.ts
+++ b/src/core/KGProject.ts
@@ -2,6 +2,7 @@ import { Expose, Type } from 'class-transformer';
import { KGTrack } from './track/KGTrack';
import { KGMidiTrack } from './track/KGMidiTrack';
import { KGAudioTrack } from './track/KGAudioTrack';
+import { KGChordTrack, KGGlobalTrack, KGMarkerTrack, KGSignatureTrack, KGTempoTrack, createDefaultGlobalTracks } from './global-track';
import { type TimeSignature, WithDefault } from '../types/projectTypes';
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
import { RESERVED_PROJECT_NAME } from '../util/projectNameUtil';
@@ -57,7 +58,7 @@ export class KGProject {
@WithDefault(0)
private projectStructureVersion: number = 0;
- public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 12;
+ public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 13;
@Expose()
@Type(() => KGTrack, {
@@ -72,8 +73,23 @@ export class KGProject {
})
private tracks: KGTrack[] = [];
+ @Expose()
+ @Type(() => KGGlobalTrack, {
+ discriminator: {
+ property: '__type',
+ subTypes: [
+ { value: KGGlobalTrack, name: 'KGGlobalTrack' },
+ { value: KGMarkerTrack, name: 'KGMarkerTrack' },
+ { value: KGTempoTrack, name: 'KGTempoTrack' },
+ { value: KGSignatureTrack, name: 'KGSignatureTrack' },
+ { value: KGChordTrack, name: 'KGChordTrack' },
+ ],
+ },
+ })
+ private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks();
+
// Constructor
- constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1) {
+ constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks()) {
this.name = name;
this.maxBars = maxBars;
this.currentBars = currentBars;
@@ -87,6 +103,7 @@ export class KGProject {
this.tracks = tracks;
this.projectStructureVersion = projectStructureVersion;
this.pianoRollZoom = pianoRollZoom;
+ this.globalTracks = globalTracks;
}
// Getters
@@ -126,6 +143,10 @@ export class KGProject {
return this.tracks;
}
+ public getGlobalTracks(): KGGlobalTrack[] {
+ return this.globalTracks;
+ }
+
// Setters
public setName(name: string): void {
this.name = name;
@@ -155,6 +176,10 @@ export class KGProject {
this.tracks = tracks;
}
+ public setGlobalTracks(globalTracks: KGGlobalTrack[]): void {
+ this.globalTracks = globalTracks;
+ }
+
public setProjectStructureVersion(projectStructureVersion: number): void {
this.projectStructureVersion = projectStructureVersion;
}
diff --git a/src/core/commands/global-region/CreateGlobalMarkerRegionCommand.ts b/src/core/commands/global-region/CreateGlobalMarkerRegionCommand.ts
new file mode 100644
index 0000000..e120121
--- /dev/null
+++ b/src/core/commands/global-region/CreateGlobalMarkerRegionCommand.ts
@@ -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;
+ }
+}
diff --git a/src/core/commands/global-region/DeleteGlobalRegionCommand.ts b/src/core/commands/global-region/DeleteGlobalRegionCommand.ts
new file mode 100644
index 0000000..674fddf
--- /dev/null
+++ b/src/core/commands/global-region/DeleteGlobalRegionCommand.ts
@@ -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`;
+ }
+}
diff --git a/src/core/commands/global-region/GlobalMarkerCommands.test.ts b/src/core/commands/global-region/GlobalMarkerCommands.test.ts
new file mode 100644
index 0000000..a321f70
--- /dev/null
+++ b/src/core/commands/global-region/GlobalMarkerCommands.test.ts
@@ -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