feat: implemented global key signature track

This commit is contained in:
Xiaohan-Tian
2026-05-23 14:44:48 -07:00
parent 1e816596e4
commit 8eb3f5d84d
30 changed files with 1689 additions and 57 deletions
+45 -1
View File
@@ -211,6 +211,10 @@
overflow: hidden; overflow: hidden;
} }
.global-tracks-grid-shell.expanded {
overflow: visible;
}
.global-tracks-info-shell.collapsed, .global-tracks-info-shell.collapsed,
.global-tracks-grid-shell.collapsed { .global-tracks-grid-shell.collapsed {
max-height: 0; max-height: 0;
@@ -254,7 +258,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
cursor: default; cursor: pointer;
} }
.global-track-add-button svg { .global-track-add-button svg {
@@ -303,6 +307,10 @@
); );
} }
.global-key-signature-lane.popup-open {
z-index: 1004;
}
.global-marker-lane.pencil-cursor { .global-marker-lane.pencil-cursor {
cursor: crosshair; cursor: crosshair;
} }
@@ -341,6 +349,42 @@
cursor: grabbing; cursor: grabbing;
} }
.global-key-signature-region {
background: #d7f0c0;
color: #24311c;
overflow: visible;
}
.global-key-signature-region .floating-popup {
min-width: 0;
width: var(--track-grid-bar-width);
}
.global-key-signature-popup-anchor {
min-width: 0;
width: var(--track-grid-bar-width);
position: absolute;
left: calc((var(--track-grid-bar-width) - var(--track-grid-bar-width)) / 2);
top: 50%;
transform: translateY(-50%);
z-index: 1008;
}
.global-key-signature-trigger {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.global-key-signature-popup-surface {
z-index: 1007;
}
.global-key-signature-popup-anchor .floating-popup-surface {
z-index: 1009;
}
.global-marker-label { .global-marker-label {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
+18 -7
View File
@@ -8,6 +8,7 @@ import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { createDefaultGlobalTracks } from '../core/global-track'; import { createDefaultGlobalTracks } from '../core/global-track';
import { createMockMidiTrack } from '../test/utils/mock-data'; import { createMockMidiTrack } from '../test/utils/mock-data';
const executeCommandMock = vi.fn();
const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4); const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4);
const anotherMidiRegion = new KGMidiRegion('region-2', '1', 0, 'Region 2', 8, 4); const anotherMidiRegion = new KGMidiRegion('region-2', '1', 0, 'Region 2', 8, 4);
const audioRegion = new KGAudioRegion('audio-1', '2', 1, 'Audio 1', 4, 4); const audioRegion = new KGAudioRegion('audio-1', '2', 1, 'Audio 1', 4, 4);
@@ -93,7 +94,7 @@ vi.mock('../core/KGCore', () => ({
clearSelectedItems: () => { clearSelectedItems: () => {
storeState.selectedRegionIds = []; storeState.selectedRegionIds = [];
}, },
executeCommand: vi.fn(), executeCommand: executeCommandMock,
getCurrentProject: () => ({ getCurrentProject: () => ({
getTracks: () => storeState.tracks, getTracks: () => storeState.tracks,
}), }),
@@ -149,6 +150,7 @@ describe('MainContent', () => {
storeState.openSpectrogramViewer.mockClear(); storeState.openSpectrogramViewer.mockClear();
storeState.addTrack.mockClear(); storeState.addTrack.mockClear();
storeState.addAudioTrack.mockClear(); storeState.addAudioTrack.mockClear();
executeCommandMock.mockClear();
}); });
it('updates activeRegionId when selecting a region with piano roll closed', () => { it('updates activeRegionId when selecting a region with piano roll closed', () => {
@@ -309,14 +311,14 @@ describe('MainContent', () => {
expect(screen.queryByText('Marker')).not.toBeInTheDocument(); expect(screen.queryByText('Marker')).not.toBeInTheDocument();
expect(screen.queryByText('Tempo')).not.toBeInTheDocument(); expect(screen.queryByText('Tempo')).not.toBeInTheDocument();
expect(screen.queryByText('Signature')).not.toBeInTheDocument(); expect(screen.queryByText('Key Signature')).not.toBeInTheDocument();
expect(screen.queryByText('Chord')).not.toBeInTheDocument(); expect(screen.queryByText('Chord')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' })); fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
expect(screen.getByText('Marker')).toBeInTheDocument(); expect(screen.getByText('Marker')).toBeInTheDocument();
expect(screen.getByText('Tempo')).toBeInTheDocument(); expect(screen.getByText('Tempo')).toBeInTheDocument();
expect(screen.getByText('Signature')).toBeInTheDocument(); expect(screen.getByText('Key Signature')).toBeInTheDocument();
expect(screen.getByText('Chord')).toBeInTheDocument(); expect(screen.getByText('Chord')).toBeInTheDocument();
const globalTracksInfoShell = screen.getByRole('button', { name: 'Add Marker global track item' }).closest('.global-tracks-info-shell') as HTMLElement; const globalTracksInfoShell = screen.getByRole('button', { name: 'Add Marker global track item' }).closest('.global-tracks-info-shell') as HTMLElement;
@@ -326,22 +328,21 @@ describe('MainContent', () => {
expect(screen.queryByText('Marker')).not.toBeInTheDocument(); expect(screen.queryByText('Marker')).not.toBeInTheDocument();
expect(screen.queryByText('Tempo')).not.toBeInTheDocument(); expect(screen.queryByText('Tempo')).not.toBeInTheDocument();
expect(screen.queryByText('Signature')).not.toBeInTheDocument(); expect(screen.queryByText('Key Signature')).not.toBeInTheDocument();
expect(screen.queryByText('Chord')).not.toBeInTheDocument(); expect(screen.queryByText('Chord')).not.toBeInTheDocument();
}); });
it('keeps the non-marker global track add buttons as visual-only controls', () => { it('keeps the tempo and chord global track add buttons as visual-only controls', () => {
render(<MainContent />); render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' })); fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
const addButtons = [ const addButtons = [
screen.getByRole('button', { name: 'Add Tempo global track item' }), screen.getByRole('button', { name: 'Add Tempo global track item' }),
screen.getByRole('button', { name: 'Add Signature global track item' }),
screen.getByRole('button', { name: 'Add Chord global track item' }), screen.getByRole('button', { name: 'Add Chord global track item' }),
]; ];
expect(addButtons).toHaveLength(3); expect(addButtons).toHaveLength(2);
addButtons.forEach(button => { addButtons.forEach(button => {
fireEvent.click(button); fireEvent.click(button);
@@ -349,5 +350,15 @@ describe('MainContent', () => {
expect(storeState.addTrack).not.toHaveBeenCalled(); expect(storeState.addTrack).not.toHaveBeenCalled();
expect(storeState.addAudioTrack).not.toHaveBeenCalled(); expect(storeState.addAudioTrack).not.toHaveBeenCalled();
expect(executeCommandMock).not.toHaveBeenCalled();
});
it('routes the signature global track add button through a command', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.click(screen.getByRole('button', { name: 'Add Key Signature global track item' }));
expect(executeCommandMock).toHaveBeenCalledTimes(1);
}); });
}); });
+114 -4
View File
@@ -3,15 +3,18 @@ import './MainContent.css';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
import type { KeySignature } from '../core/KGProject';
import { GlobalTrackType } from '../core/global-track'; import { GlobalTrackType } from '../core/global-track';
import { KGTrack } from '../core/track/KGTrack'; import { KGTrack } from '../core/track/KGTrack';
import { KGRegion } from '../core/region/KGRegion'; import { KGRegion } from '../core/region/KGRegion';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion'; import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { KGMarkerRegion } from '../core/region/KGMarkerRegion'; import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
import TrackInfoPanel from './track/TrackInfoPanel'; import TrackInfoPanel from './track/TrackInfoPanel';
import TrackGridPanel from './track/TrackGridPanel'; import TrackGridPanel from './track/TrackGridPanel';
import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane';
import GlobalMarkerLane from './global-track/GlobalMarkerLane'; import GlobalMarkerLane from './global-track/GlobalMarkerLane';
import PianoRoll from './piano-roll/PianoRoll'; import PianoRoll from './piano-roll/PianoRoll';
import { TrackCreateDialog } from './common'; import { TrackCreateDialog } from './common';
@@ -23,13 +26,18 @@ import { KGMainContentState } from '../core/state/KGMainContentState';
import { import {
ChangeLoopSettingsCommand, ChangeLoopSettingsCommand,
CreateGlobalMarkerRegionCommand, CreateGlobalMarkerRegionCommand,
CreateKeySignatureRegionCommand,
DeleteKeySignatureRegionCommand,
DeleteMultipleKeySignatureRegionsCommand,
DeleteMultipleGlobalRegionsCommand, DeleteMultipleGlobalRegionsCommand,
DeleteTrackAutomationPointsCommand, DeleteTrackAutomationPointsCommand,
MoveGlobalRegionCommand, MoveGlobalRegionCommand,
ResizeKeySignatureRegionCommand,
ResizeGlobalRegionCommand, ResizeGlobalRegionCommand,
UpdateKeySignatureRegionCommand,
UpdateGlobalRegionTextCommand, UpdateGlobalRegionTextCommand,
} from '../core/commands'; } from '../core/commands';
import { DEFAULT_MARKER_REGION_NAME } from '../util/globalTrackUtil'; import { DEFAULT_MARKER_REGION_NAME, getSortedKeySignatureRegions } from '../util/globalTrackUtil';
import { FaPlus } from 'react-icons/fa'; import { FaPlus } from 'react-icons/fa';
import { FaSquareArrowUpRight } from 'react-icons/fa6'; import { FaSquareArrowUpRight } from 'react-icons/fa6';
@@ -45,7 +53,7 @@ interface GlobalTrackDefinition {
const GLOBAL_TRACKS: GlobalTrackDefinition[] = [ const GLOBAL_TRACKS: GlobalTrackDefinition[] = [
{ id: 'marker', label: 'Marker' }, { id: 'marker', label: 'Marker' },
{ id: 'tempo', label: 'Tempo' }, { id: 'tempo', label: 'Tempo' },
{ id: 'signature', label: 'Signature' }, { id: 'signature', label: 'Key Signature' },
{ id: 'chord', label: 'Chord' }, { id: 'chord', label: 'Chord' },
]; ];
@@ -110,6 +118,7 @@ const MainContent: React.FC<MainContentProps> = ({
const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false); const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false);
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null); const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState(''); const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
// Use the region operations hook // Use the region operations hook
const { deleteSelectedRegions } = useRegionOperations({ const { deleteSelectedRegions } = useRegionOperations({
@@ -162,6 +171,8 @@ const MainContent: React.FC<MainContentProps> = ({
const markerRegions = (markerTrack?.getRegions() ?? []).filter( const markerRegions = (markerTrack?.getRegions() ?? []).filter(
(region): region is KGMarkerRegion => region instanceof KGMarkerRegion (region): region is KGMarkerRegion => region instanceof KGMarkerRegion
); );
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
const signatureRegions = signatureTrack ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator) : [];
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => { const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
for (const track of tracks) { for (const track of tracks) {
@@ -193,18 +204,35 @@ const MainContent: React.FC<MainContentProps> = ({
} }
try { try {
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(selectedGlobalRegionIds)); const signatureRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGKeySignatureRegion);
const markerRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGMarkerRegion);
if (signatureRegionIds.length > 0 && markerRegionIds.length === 0) {
KGCore.instance().executeCommand(
signatureRegionIds.length === 1
? new DeleteKeySignatureRegionCommand(signatureRegionIds[0])
: new DeleteMultipleKeySignatureRegionsCommand(signatureRegionIds)
);
} else if (markerRegionIds.length > 0 && signatureRegionIds.length === 0) {
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(markerRegionIds));
} else {
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(selectedGlobalRegionIds));
}
if (editingGlobalRegionId && selectedGlobalRegionIds.includes(editingGlobalRegionId)) { if (editingGlobalRegionId && selectedGlobalRegionIds.includes(editingGlobalRegionId)) {
setEditingGlobalRegionId(null); setEditingGlobalRegionId(null);
setEditingGlobalRegionText(''); setEditingGlobalRegionText('');
} }
if (editingKeySignatureRegionId && selectedGlobalRegionIds.includes(editingKeySignatureRegionId)) {
setEditingKeySignatureRegionId(null);
}
refreshProjectState(); refreshProjectState();
return true; return true;
} catch (error) { } catch (error) {
console.error('Error deleting global marker regions:', error); console.error('Error deleting global marker regions:', error);
return false; return false;
} }
}, [editingGlobalRegionId, isGlobalRegionId, refreshProjectState, selectedRegionIds]); }, [editingGlobalRegionId, editingKeySignatureRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
// Register the delete function with the global manager // Register the delete function with the global manager
useEffect(() => { useEffect(() => {
@@ -877,6 +905,15 @@ const MainContent: React.FC<MainContentProps> = ({
setEditingGlobalRegionText(region.getName()); setEditingGlobalRegionText(region.getName());
}, [findProjectRegionById]); }, [findProjectRegionById]);
const beginEditingKeySignatureRegion = useCallback((regionId: string) => {
const region = findProjectRegionById(regionId);
if (!(region instanceof KGKeySignatureRegion)) {
return;
}
setEditingKeySignatureRegionId(regionId);
}, [findProjectRegionById]);
const commitGlobalRegionEdit = useCallback((regionId: string) => { const commitGlobalRegionEdit = useCallback((regionId: string) => {
const region = findProjectRegionById(regionId); const region = findProjectRegionById(regionId);
if (!(region instanceof KGMarkerRegion)) { if (!(region instanceof KGMarkerRegion)) {
@@ -956,6 +993,57 @@ const MainContent: React.FC<MainContentProps> = ({
} }
}, [refreshProjectState]); }, [refreshProjectState]);
const createKeySignatureAtBar = useCallback((requestedStartBar: number) => {
const normalizedStartBar = Math.max(0, Math.min(requestedStartBar, maxBars - 1));
const existingRegionAtStart = signatureRegions.find(region => region.getStartBar() === normalizedStartBar);
if (existingRegionAtStart) {
selectGlobalRegion(existingRegionAtStart.getId(), { shiftKey: false });
beginEditingKeySignatureRegion(existingRegionAtStart.getId());
return;
}
try {
const command = new CreateKeySignatureRegionCommand(normalizedStartBar);
KGCore.instance().executeCommand(command);
refreshProjectState();
const createdRegion = command.getCreatedRegion();
if (!createdRegion) {
return;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
setEditingKeySignatureRegionId(createdRegion.getId());
} catch (error) {
console.error('Error creating key signature region:', error);
}
}, [beginEditingKeySignatureRegion, maxBars, refreshProjectState, selectGlobalRegion, signatureRegions]);
const createKeySignatureAtPlayheadBar = useCallback(() => {
const beatsPerBar = timeSignature.numerator;
const startBar = Math.floor(playheadPosition / beatsPerBar);
createKeySignatureAtBar(startBar);
}, [createKeySignatureAtBar, playheadPosition, timeSignature.numerator]);
const resizeKeySignatureRegion = useCallback((regionId: string, edge: 'start' | 'end', bar: number) => {
try {
KGCore.instance().executeCommand(new ResizeKeySignatureRegionCommand(regionId, edge, Math.round(bar)));
refreshProjectState();
} catch (error) {
console.error('Error resizing key signature region:', error);
}
}, [refreshProjectState]);
const updateKeySignatureRegion = useCallback((regionId: string, keySignature: KeySignature) => {
try {
KGCore.instance().executeCommand(new UpdateKeySignatureRegionCommand(regionId, keySignature));
setEditingKeySignatureRegionId(null);
refreshProjectState();
} catch (error) {
console.error('Error updating key signature region:', error);
}
}, [refreshProjectState]);
/** /**
* Add keyboard event listener for region deletion * Add keyboard event listener for region deletion
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions * Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
@@ -1306,6 +1394,11 @@ const MainContent: React.FC<MainContentProps> = ({
e.stopPropagation(); e.stopPropagation();
if (track.id === 'marker') { if (track.id === 'marker') {
createMarkerAtPlayheadBar(); createMarkerAtPlayheadBar();
return;
}
if (track.id === 'signature') {
createKeySignatureAtPlayheadBar();
} }
}} }}
> >
@@ -1323,6 +1416,7 @@ const MainContent: React.FC<MainContentProps> = ({
key={track.id} key={track.id}
markerRegions={markerRegions} markerRegions={markerRegions}
maxBars={maxBars} maxBars={maxBars}
barWidthMultiplier={barWidthMultiplier}
timeSignature={timeSignature} timeSignature={timeSignature}
selectedRegionIds={selectedRegionIds} selectedRegionIds={selectedRegionIds}
editingRegionId={editingGlobalRegionId} editingRegionId={editingGlobalRegionId}
@@ -1339,6 +1433,22 @@ const MainContent: React.FC<MainContentProps> = ({
onMoveRegion={moveGlobalMarkerRegion} onMoveRegion={moveGlobalMarkerRegion}
onResizeRegion={resizeGlobalMarkerRegion} onResizeRegion={resizeGlobalMarkerRegion}
/> />
) : track.id === 'signature' ? (
<GlobalKeySignatureLane
key={track.id}
signatureRegions={signatureRegions}
maxBars={maxBars}
barWidthMultiplier={barWidthMultiplier}
timeSignature={timeSignature}
selectedRegionIds={selectedRegionIds}
pickerRegionId={editingKeySignatureRegionId}
onClosePicker={() => setEditingKeySignatureRegionId(null)}
onSelectRegion={selectGlobalRegion}
onCreateAtBar={createKeySignatureAtBar}
onResizeRegion={resizeKeySignatureRegion}
onChangeKeySignature={updateKeySignatureRegion}
onOpenPicker={beginEditingKeySignatureRegion}
/>
) : ( ) : (
<div <div
key={track.id} key={track.id}
+29 -1
View File
@@ -2,7 +2,10 @@ import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import Toolbar from './Toolbar'; import Toolbar from './Toolbar';
import { createDefaultGlobalTracks } from '../core/global-track';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
const executeCommandMock = vi.fn();
const storeState = { const storeState = {
projectName: 'Test Project', projectName: 'Test Project',
setProjectName: vi.fn(), setProjectName: vi.fn(),
@@ -59,6 +62,7 @@ const storeState = {
requestMainContentScroll: vi.fn(), requestMainContentScroll: vi.fn(),
requestPianoRollScroll: vi.fn(), requestPianoRollScroll: vi.fn(),
tracks: [] as unknown[], tracks: [] as unknown[],
globalTracks: createDefaultGlobalTracks(),
}; };
type StoreState = typeof storeState; type StoreState = typeof storeState;
@@ -104,7 +108,7 @@ vi.mock('../util/projectNameUtil', () => ({
isReservedProjectName: vi.fn(() => false), isReservedProjectName: vi.fn(() => false),
RESERVED_PROJECT_NAME: 'Untitled Project', RESERVED_PROJECT_NAME: 'Untitled Project',
})); }));
vi.mock('../core/KGCore', () => ({ KGCore: { instance: vi.fn(() => ({ getCurrentProject: vi.fn(() => ({ getTracks: () => [] })) })) } })); vi.mock('../core/KGCore', () => ({ KGCore: { instance: vi.fn(() => ({ getCurrentProject: vi.fn(() => ({ getTracks: () => [] })), executeCommand: executeCommandMock })) } }));
vi.mock('../core/midi-input/KGMidiInput', () => ({ KGMidiInput: { instance: vi.fn(() => ({ getConnectedInputCount: () => 0 })) } })); vi.mock('../core/midi-input/KGMidiInput', () => ({ KGMidiInput: { instance: vi.fn(() => ({ getConnectedInputCount: () => 0 })) } }));
vi.mock('../core/region/KGMidiRegion', () => ({ KGMidiRegion: class {} })); vi.mock('../core/region/KGMidiRegion', () => ({ KGMidiRegion: class {} }));
vi.mock('../core/track/KGAudioTrack', () => ({ KGAudioTrack: class {} })); vi.mock('../core/track/KGAudioTrack', () => ({ KGAudioTrack: class {} }));
@@ -207,7 +211,11 @@ describe('Toolbar settings side-panel behavior', () => {
storeState.showKGOnePanel = true; storeState.showKGOnePanel = true;
storeState.showEventListPanel = false; storeState.showEventListPanel = false;
storeState.keySignature = 'C major'; storeState.keySignature = 'C major';
storeState.playheadPosition = 0;
storeState.globalTracks = createDefaultGlobalTracks();
storeState.setKeySignature.mockClear(); storeState.setKeySignature.mockClear();
storeState.refreshProjectState.mockClear();
executeCommandMock.mockClear();
}); });
it('suppresses active styling for side-panel buttons while Settings is visible', () => { it('suppresses active styling for side-panel buttons while Settings is visible', () => {
@@ -273,4 +281,24 @@ describe('Toolbar settings side-panel behavior', () => {
expect(storeState.setKeySignature).toHaveBeenCalledWith('E minor'); expect(storeState.setKeySignature).toHaveBeenCalledWith('E minor');
expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor'); expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor');
}); });
it('shows the effective region key at the playhead and updates that region instead of the project default', () => {
const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 4, 4),
]);
storeState.playheadPosition = 8;
render(<Toolbar />);
expect(screen.getByRole('button', { name: /choose key signature, current G major/i })).toHaveTextContent('G major');
fireEvent.click(screen.getByRole('button', { name: /choose key signature/i }));
fireEvent.click(screen.getByRole('button', { name: 'Select E minor' }));
expect(executeCommandMock).toHaveBeenCalledTimes(1);
expect(storeState.setKeySignature).not.toHaveBeenCalled();
expect(storeState.refreshProjectState).toHaveBeenCalled();
expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor');
});
}); });
+27 -5
View File
@@ -15,7 +15,9 @@ import {
FaCog, FaMagnet, FaCut, FaCircle, FaCompress FaCog, FaMagnet, FaCut, FaCircle, FaCompress
} from 'react-icons/fa'; } from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject'; import { KGProject, type KeySignature } from '../core/KGProject';
import { GlobalTrackType } from '../core/global-track';
import { KGMidiInput } from '../core/midi-input/KGMidiInput'; import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
@@ -36,6 +38,7 @@ import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon'; import MetronomeIcon from './common/icons/MetronomeIcon';
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil'; import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
import { UpdateKeySignatureRegionCommand } from '../core/commands';
const Toolbar: React.FC = () => { const Toolbar: React.FC = () => {
const { const {
@@ -47,6 +50,7 @@ const Toolbar: React.FC = () => {
maxBars, setMaxBars, maxBars, setMaxBars,
barWidthMultiplier, setBarWidthMultiplier, barWidthMultiplier, setBarWidthMultiplier,
isLooping, toggleLoop, isLooping, toggleLoop,
globalTracks,
canUndo, canRedo, undoDescription, redoDescription, undo, redo, canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, activateSidePanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled, toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, activateSidePanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
isRecording, startRecording, stopRecording, isRecording, startRecording, stopRecording,
@@ -66,6 +70,16 @@ const Toolbar: React.FC = () => {
// State for key signature dropdown // State for key signature dropdown
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false); const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
const signatureRegions = (signatureTrack?.getRegions() ?? [])
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
.sort((left, right) => left.getStartBar() - right.getStartBar());
const playheadBar = Math.floor(playheadPosition / timeSignature.numerator);
const activeKeySignatureRegion = signatureRegions.find(
region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar()
) ?? null;
const displayedKeySignature = activeKeySignatureRegion?.getKeySignature() ?? keySignature;
// State for export dropdown // State for export dropdown
const [showExportDropdown, setShowExportDropdown] = React.useState(false); const [showExportDropdown, setShowExportDropdown] = React.useState(false);
@@ -691,10 +705,18 @@ const Toolbar: React.FC = () => {
const handleKeySignatureChange = (newKeySignature: string) => { const handleKeySignatureChange = (newKeySignature: string) => {
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("Key signature changed from", keySignature, "to", newKeySignature); console.log("Key signature changed from", displayedKeySignature, "to", newKeySignature);
} }
setKeySignature(newKeySignature as KeySignature); if (activeKeySignatureRegion) {
KGCore.instance().executeCommand(new UpdateKeySignatureRegionCommand(
activeKeySignatureRegion.getId(),
newKeySignature as KeySignature
));
refreshProjectState();
} else {
setKeySignature(newKeySignature as KeySignature);
}
setStatus(`Key signature changed to ${newKeySignature}`); setStatus(`Key signature changed to ${newKeySignature}`);
setShowKeySignatureDropdown(false); setShowKeySignatureDropdown(false);
}; };
@@ -1167,13 +1189,13 @@ const Toolbar: React.FC = () => {
onClick={() => setShowKeySignatureDropdown((current) => !current)} onClick={() => setShowKeySignatureDropdown((current) => !current)}
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={showKeySignatureDropdown} aria-expanded={showKeySignatureDropdown}
aria-label={`Choose key signature, current ${keySignature}`} aria-label={`Choose key signature, current ${displayedKeySignature}`}
> >
{keySignature} {displayedKeySignature}
</button> </button>
)} )}
> >
<KeySignaturePickerPopup value={keySignature} onChange={handleKeySignatureChange} /> <KeySignaturePickerPopup value={displayedKeySignature} onChange={handleKeySignatureChange} />
</FloatingPopup> </FloatingPopup>
</div> </div>
</div> </div>
+78 -18
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom';
import './FloatingPopup.css'; import './FloatingPopup.css';
type FloatingPopupPlacement = 'bottom' | 'top' | 'left' | 'right'; type FloatingPopupPlacement = 'bottom' | 'top' | 'left' | 'right';
@@ -14,6 +15,7 @@ interface FloatingPopupProps {
contentClassName?: string; contentClassName?: string;
panelClassName?: string; panelClassName?: string;
arrowClassName?: string; arrowClassName?: string;
renderInPortal?: boolean;
} }
const FloatingPopup: React.FC<FloatingPopupProps> = ({ const FloatingPopup: React.FC<FloatingPopupProps> = ({
@@ -27,8 +29,63 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
contentClassName = '', contentClassName = '',
panelClassName = '', panelClassName = '',
arrowClassName = '', arrowClassName = '',
renderInPortal = false,
}) => { }) => {
const rootRef = React.useRef<HTMLDivElement | null>(null); const rootRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLDivElement | null>(null);
const [portalStyle, setPortalStyle] = React.useState<React.CSSProperties | undefined>(undefined);
React.useLayoutEffect(() => {
if (!isOpen || !renderInPortal || !triggerRef.current) {
return;
}
const updatePortalStyle = () => {
if (!triggerRef.current) {
return;
}
const rect = triggerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const placementOffset = 14;
if (placement === 'bottom') {
setPortalStyle({
position: 'fixed',
top: `${rect.bottom + placementOffset}px`,
left: `${centerX}px`,
transform: 'translateX(-50%)',
});
return;
}
if (placement === 'top') {
setPortalStyle({
position: 'fixed',
bottom: `${window.innerHeight - rect.top + placementOffset}px`,
left: `${centerX}px`,
transform: 'translateX(-50%)',
});
return;
}
setPortalStyle({
position: 'fixed',
top: `${rect.bottom + placementOffset}px`,
left: `${centerX}px`,
transform: 'translateX(-50%)',
});
};
updatePortalStyle();
window.addEventListener('resize', updatePortalStyle);
window.addEventListener('scroll', updatePortalStyle, true);
return () => {
window.removeEventListener('resize', updatePortalStyle);
window.removeEventListener('scroll', updatePortalStyle, true);
};
}, [isOpen, placement, renderInPortal]);
React.useEffect(() => { React.useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -56,28 +113,31 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
const popupSurface = isOpen ? (
<div
className={`floating-popup-surface ${contentClassName}`.trim()}
data-placement={placement}
role="dialog"
aria-modal="false"
style={renderInPortal ? portalStyle : undefined}
>
<div
className={`floating-popup-arrow ${arrowClassName}`.trim()}
data-placement={placement}
aria-hidden="true"
/>
<div className={`floating-popup-panel ${panelClassName}`.trim()}>
{children}
</div>
</div>
) : null;
return ( return (
<div className={`floating-popup ${className}`.trim()} ref={rootRef}> <div className={`floating-popup ${className}`.trim()} ref={rootRef}>
<div className={`floating-popup-trigger ${triggerClassName}`.trim()}> <div className={`floating-popup-trigger ${triggerClassName}`.trim()} ref={triggerRef}>
{trigger} {trigger}
</div> </div>
{isOpen && ( {renderInPortal && popupSurface ? createPortal(popupSurface, document.body) : popupSurface}
<div
className={`floating-popup-surface ${contentClassName}`.trim()}
data-placement={placement}
role="dialog"
aria-modal="false"
>
<div
className={`floating-popup-arrow ${arrowClassName}`.trim()}
data-placement={placement}
aria-hidden="true"
/>
<div className={`floating-popup-panel ${panelClassName}`.trim()}>
{children}
</div>
</div>
)}
</div> </div>
); );
}; };
@@ -0,0 +1,74 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import GlobalKeySignatureLane from './GlobalKeySignatureLane';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
describe('GlobalKeySignatureLane', () => {
const baseRegion = new KGKeySignatureRegion('sig-1', 'global-signature', 2, 'C major', 0, 4, 4);
beforeEach(() => {
document.documentElement.style.setProperty('--track-grid-bar-width', '40');
});
it('opens the key signature picker for an existing region', () => {
const onOpenPicker = vi.fn();
render(
<GlobalKeySignatureLane
signatureRegions={[baseRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={[]}
pickerRegionId={null}
onClosePicker={vi.fn()}
onSelectRegion={vi.fn()}
onCreateAtBar={vi.fn()}
onResizeRegion={vi.fn()}
onChangeKeySignature={vi.fn()}
onOpenPicker={onOpenPicker}
/>
);
fireEvent.doubleClick(screen.getByText('C major'));
expect(onOpenPicker).toHaveBeenCalledWith('sig-1');
});
it('creates a new region at a bar-aligned position on empty-lane double click', () => {
const onCreateAtBar = vi.fn();
const { container } = render(
<GlobalKeySignatureLane
signatureRegions={[baseRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={[]}
pickerRegionId={null}
onClosePicker={vi.fn()}
onSelectRegion={vi.fn()}
onCreateAtBar={onCreateAtBar}
onResizeRegion={vi.fn()}
onChangeKeySignature={vi.fn()}
onOpenPicker={vi.fn()}
/>
);
const lane = container.querySelector('.global-key-signature-lane') as HTMLDivElement;
vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 320,
bottom: 24,
width: 320,
height: 24,
toJSON: () => ({}),
});
fireEvent.doubleClick(lane, { clientX: 159, clientY: 10 });
expect(onCreateAtBar).toHaveBeenCalledWith(3);
});
});
@@ -0,0 +1,364 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { KeySignature } from '../../core/KGProject';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import type { RegionClickOptions } from '../interfaces';
import { isModifierKeyPressed } from '../../util/osUtil';
import { TOOLBAR_CONSTANTS } from '../../constants';
import FloatingPopup from '../common/FloatingPopup';
import KeySignaturePickerPopup from '../KeySignaturePickerPopup';
interface GlobalKeySignatureLaneProps {
signatureRegions: KGKeySignatureRegion[];
maxBars: number;
barWidthMultiplier: number;
timeSignature: { numerator: number; denominator: number };
selectedRegionIds: string[];
pickerRegionId: string | null;
onClosePicker: () => void;
onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
onCreateAtBar: (startBar: number) => void;
onResizeRegion: (regionId: string, edge: 'start' | 'end', bar: number) => void;
onChangeKeySignature: (regionId: string, keySignature: KeySignature) => void;
onOpenPicker: (regionId: string) => void;
}
type ResizeEdge = 'start' | 'end' | null;
const REGION_EDGE_HITBOX_PX = 8;
const DRAG_THRESHOLD_PX = 4;
const GlobalKeySignatureLane: React.FC<GlobalKeySignatureLaneProps> = ({
signatureRegions,
maxBars,
barWidthMultiplier,
timeSignature,
selectedRegionIds,
pickerRegionId,
onClosePicker,
onSelectRegion,
onCreateAtBar,
onResizeRegion,
onChangeKeySignature,
onOpenPicker,
}) => {
const laneRef = useRef<HTMLDivElement | null>(null);
const [previewBars, setPreviewBars] = useState<Record<string, { startBar: number; lengthBars: number }>>({});
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
const [isModifierPressed, setIsModifierPressed] = useState(false);
const interactionRef = useRef<{
mode: 'resize' | null;
regionId: string;
resizeEdge: ResizeEdge;
initialMouseX: number;
moved: boolean;
} | null>(null);
const barWidth = useMemo(
() => TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier,
[barWidthMultiplier]
);
const regionOrder = useMemo(
() => signatureRegions.map(region => region.getId()),
[signatureRegions]
);
const getRegionIndex = (regionId: string) => regionOrder.findIndex(candidateId => candidateId === regionId);
const canResizeEdge = (regionId: string, edge: 'start' | 'end') => {
const regionIndex = getRegionIndex(regionId);
if (regionIndex === -1) {
return false;
}
if (edge === 'start') {
return regionIndex > 0;
}
return regionIndex < signatureRegions.length - 1;
};
const getRenderedBarState = (region: KGKeySignatureRegion) => (
previewBars[region.getId()] ?? {
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}
);
const getResizeEdgeFromMouseEvent = (event: React.MouseEvent<HTMLDivElement, MouseEvent>): ResizeEdge => {
const rect = event.currentTarget.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
if (offsetX <= REGION_EDGE_HITBOX_PX) {
return 'start';
}
if (rect.width - offsetX <= REGION_EDGE_HITBOX_PX) {
return 'end';
}
return null;
};
const getBarFromClientX = (clientX: number) => {
if (!laneRef.current) {
return 0;
}
const rect = laneRef.current.getBoundingClientRect();
const relativeX = clientX - rect.left;
return Math.max(0, Math.min(maxBars, Math.round(relativeX / barWidth)));
};
const getCreateBarFromClientX = (clientX: number) => {
if (!laneRef.current) {
return 0;
}
const rect = laneRef.current.getBoundingClientRect();
const relativeX = clientX - rect.left;
return Math.max(0, Math.min(maxBars - 1, Math.floor(relativeX / barWidth)));
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isModifierKeyPressed(event)) {
setIsModifierPressed(true);
}
if (event.key === 'Escape') {
onClosePicker();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (!isModifierKeyPressed(event)) {
setIsModifierPressed(false);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [onClosePicker]);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (!interactionRef.current) {
return;
}
const interaction = interactionRef.current;
const deltaX = event.clientX - interaction.initialMouseX;
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
interaction.moved = true;
}
if (interaction.mode !== 'resize' || !interaction.resizeEdge) {
return;
}
const targetRegion = signatureRegions.find(region => region.getId() === interaction.regionId);
if (!targetRegion) {
return;
}
const targetIndex = getRegionIndex(interaction.regionId);
const desiredBoundaryBar = getBarFromClientX(event.clientX);
if (interaction.resizeEdge === 'start' && targetIndex > 0) {
const previousRegion = signatureRegions[targetIndex - 1];
const targetEndBar = targetRegion.getEndBar();
const clampedBoundaryBar = Math.max(
previousRegion.getStartBar() + 1,
Math.min(desiredBoundaryBar, targetEndBar - 1)
);
setPreviewBars({
[previousRegion.getId()]: {
startBar: previousRegion.getStartBar(),
lengthBars: clampedBoundaryBar - previousRegion.getStartBar(),
},
[targetRegion.getId()]: {
startBar: clampedBoundaryBar,
lengthBars: targetEndBar - clampedBoundaryBar,
},
});
return;
}
if (interaction.resizeEdge === 'end' && targetIndex < signatureRegions.length - 1) {
const nextRegion = signatureRegions[targetIndex + 1];
const nextEndBar = nextRegion.getEndBar();
const clampedBoundaryBar = Math.max(
targetRegion.getStartBar() + 1,
Math.min(desiredBoundaryBar, nextEndBar - 1)
);
setPreviewBars({
[targetRegion.getId()]: {
startBar: targetRegion.getStartBar(),
lengthBars: clampedBoundaryBar - targetRegion.getStartBar(),
},
[nextRegion.getId()]: {
startBar: clampedBoundaryBar,
lengthBars: nextEndBar - clampedBoundaryBar,
},
});
}
};
const handleMouseUp = (event: MouseEvent) => {
if (!interactionRef.current) {
return;
}
const interaction = interactionRef.current;
interactionRef.current = null;
const resizeEdge = interaction.resizeEdge;
const shouldResize = interaction.moved && resizeEdge !== null;
setPreviewBars({});
if (!shouldResize) {
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
return;
}
onResizeRegion(interaction.regionId, resizeEdge, getBarFromClientX(event.clientX));
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [getBarFromClientX, onResizeRegion, onSelectRegion, signatureRegions]);
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
if (!(event.target instanceof HTMLElement) || event.target.closest('.global-key-signature-region')) {
return;
}
if (!isModifierKeyPressed(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
onCreateAtBar(getCreateBarFromClientX(event.clientX));
};
const handleLaneDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!(event.target instanceof HTMLElement) || event.target.closest('.global-key-signature-region')) {
return;
}
event.preventDefault();
event.stopPropagation();
onCreateAtBar(getCreateBarFromClientX(event.clientX));
};
return (
<>
<div
ref={laneRef}
className={`global-marker-lane global-key-signature-lane${pickerRegionId ? ' popup-open' : ''}${isModifierPressed ? ' pencil-cursor' : ''}`}
onMouseDown={handleLaneMouseDown}
onDoubleClick={handleLaneDoubleClick}
>
{signatureRegions.map(region => {
const rendered = getRenderedBarState(region);
const isSelected = selectedRegionIds.includes(region.getId());
const left = rendered.startBar * barWidth;
const clampedEndBar = Math.max(rendered.startBar, Math.min(rendered.startBar + rendered.lengthBars, maxBars));
const widthBars = Math.max(0, clampedEndBar - rendered.startBar);
const width = Math.max(barWidth, widthBars * barWidth);
if (rendered.startBar >= maxBars || widthBars <= 0) {
return null;
}
return (
<div
key={region.getId()}
className={`global-marker-region global-key-signature-region${isSelected ? ' selected' : ''}`}
style={{
left: `${left}px`,
width: `${width}px`,
cursor: hoverEdges[region.getId()] ? 'col-resize' : 'pointer',
zIndex: pickerRegionId === region.getId() ? 1006 : 1,
}}
onMouseEnter={() => setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
onMouseMove={(event) => {
const nextEdge = getResizeEdgeFromMouseEvent(event);
const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
setHoverEdges(prev => ({ ...prev, [region.getId()]: normalizedEdge }));
}}
onMouseLeave={() => setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
onMouseDown={(event) => {
if (event.button !== 0) {
return;
}
const nextEdge = getResizeEdgeFromMouseEvent(event);
const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
if (!normalizedEdge) {
return;
}
event.preventDefault();
event.stopPropagation();
interactionRef.current = {
mode: 'resize',
regionId: region.getId(),
resizeEdge: normalizedEdge,
initialMouseX: event.clientX,
moved: false,
};
}}
onClick={(event) => {
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
}}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenPicker(region.getId());
}}
>
<span className="global-marker-label">{region.getKeySignature()}</span>
<FloatingPopup
isOpen={pickerRegionId === region.getId()}
onClose={onClosePicker}
placement="bottom"
className="key-signature-popup-anchor global-key-signature-popup-anchor"
contentClassName="key-signature-popup-surface global-key-signature-popup-surface"
panelClassName="key-signature-popup-panel global-key-signature-popup-panel"
arrowClassName="key-signature-popup-arrow global-key-signature-popup-arrow"
renderInPortal
trigger={(
<span className="global-key-signature-trigger" aria-hidden="true" />
)}
>
<KeySignaturePickerPopup
value={region.getKeySignature()}
onChange={(keySignature) => onChangeKeySignature(region.getId(), keySignature)}
/>
</FloatingPopup>
</div>
);
})}
</div>
</>
);
};
export default GlobalKeySignatureLane;
@@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion'; import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
import type { RegionClickOptions } from '../interfaces'; import type { RegionClickOptions } from '../interfaces';
import { isModifierKeyPressed } from '../../util/osUtil'; import { isModifierKeyPressed } from '../../util/osUtil';
import { TOOLBAR_CONSTANTS } from '../../constants';
interface GlobalMarkerLaneProps { interface GlobalMarkerLaneProps {
markerRegions: KGMarkerRegion[]; markerRegions: KGMarkerRegion[];
maxBars: number; maxBars: number;
barWidthMultiplier: number;
timeSignature: { numerator: number; denominator: number }; timeSignature: { numerator: number; denominator: number };
selectedRegionIds: string[]; selectedRegionIds: string[];
editingRegionId: string | null; editingRegionId: string | null;
@@ -28,6 +30,7 @@ const DRAG_THRESHOLD_PX = 4;
const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({ const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
markerRegions, markerRegions,
maxBars, maxBars,
barWidthMultiplier,
timeSignature, timeSignature,
selectedRegionIds, selectedRegionIds,
editingRegionId, editingRegionId,
@@ -57,11 +60,9 @@ const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
const totalBeats = maxBars * timeSignature.numerator; const totalBeats = maxBars * timeSignature.numerator;
const beatWidth = useMemo(() => { const beatWidth = useMemo(() => {
const barWidth = parseInt( const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
) || 40;
return barWidth / timeSignature.numerator; return barWidth / timeSignature.numerator;
}, [timeSignature.numerator]); }, [barWidthMultiplier, timeSignature.numerator]);
const clampStartBeat = (value: number) => Math.max(0, Math.min(totalBeats - 1, value)); const clampStartBeat = (value: number) => Math.max(0, Math.min(totalBeats - 1, value));
const clampEndBeat = (value: number) => Math.max(1, Math.min(totalBeats, value)); const clampEndBeat = (value: number) => Math.max(1, Math.min(totalBeats, value));
@@ -5,20 +5,29 @@ import SheetMusicView from './SheetMusicView';
import { getSheetPlayheadPixel, parseSheetQuantization } from './sheetNotation'; import { getSheetPlayheadPixel, parseSheetQuantization } from './sheetNotation';
import type { SheetMeasureMetric } from './sheetNotationTypes'; import type { SheetMeasureMetric } from './sheetNotationTypes';
import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data'; import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data';
import { createDefaultGlobalTracks } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
const setPlayheadPosition = vi.fn(); const setPlayheadPosition = vi.fn();
const requestMainContentScroll = vi.fn(); const requestMainContentScroll = vi.fn();
const vexflowMocks = vi.hoisted(() => ({
addKeySignatureMock: vi.fn(),
applyAccidentalsMock: vi.fn(),
}));
const storeState = {
playheadPosition: 0,
setPlayheadPosition,
requestMainContentScroll,
globalTracks: createDefaultGlobalTracks(),
};
vi.mock('../../stores/projectStore', () => ({ vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector: (state: { useProjectStore: (selector: (state: {
playheadPosition: number; playheadPosition: number;
setPlayheadPosition: typeof setPlayheadPosition; setPlayheadPosition: typeof setPlayheadPosition;
requestMainContentScroll: typeof requestMainContentScroll; requestMainContentScroll: typeof requestMainContentScroll;
}) => unknown) => selector({ globalTracks: typeof storeState.globalTracks;
playheadPosition: 0, }) => unknown) => selector(storeState),
setPlayheadPosition,
requestMainContentScroll,
}),
})); }));
vi.mock('../common', () => ({ vi.mock('../common', () => ({
@@ -66,6 +75,7 @@ vi.mock('vexflow', () => {
} }
addKeySignature() { addKeySignature() {
vexflowMocks.addKeySignatureMock(...arguments);
return this; return this;
} }
@@ -143,7 +153,7 @@ vi.mock('vexflow', () => {
} }
return { return {
Accidental: { applyAccidentals: vi.fn() }, Accidental: { applyAccidentals: vexflowMocks.applyAccidentalsMock },
BarlineType: { SINGLE: 1, NONE: 0 }, BarlineType: { SINGLE: 1, NONE: 0 },
Beam: MockBeam, Beam: MockBeam,
Dot: { buildAndAttach: vi.fn() }, Dot: { buildAndAttach: vi.fn() },
@@ -169,6 +179,9 @@ describe('SheetMusicView', () => {
setPlayheadPosition.mockClear(); setPlayheadPosition.mockClear();
requestMainContentScroll.mockClear(); requestMainContentScroll.mockClear();
onMetricsChange.mockClear(); onMetricsChange.mockClear();
vexflowMocks.addKeySignatureMock.mockClear();
vexflowMocks.applyAccidentalsMock.mockClear();
storeState.globalTracks = createDefaultGlobalTracks();
}); });
it('maps header clicks in region scope without adding scroll offset', () => { it('maps header clicks in region scope without adding scroll offset', () => {
@@ -299,4 +312,64 @@ describe('SheetMusicView', () => {
expect(screen.getByTestId('playhead')).toBeInTheDocument(); expect(screen.getByTestId('playhead')).toBeInTheDocument();
}); });
it('renders sheet measures using effective key signatures from the global signature track', () => {
const activeRegion = createMockMidiRegion({
startFromBeat: 0,
length: 12,
notes: [createMockMidiNote({ startBeat: 0, endBeat: 1, pitch: 60 })],
});
const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 1, 2, 4),
]);
render(
<SheetMusicView
activeRegion={activeRegion}
midiRegions={[activeRegion]}
maxBars={8}
sheetMusicTrackScopeEnabled={false}
timeSignature={{ numerator: 4, denominator: 4 }}
keySignature="C major"
instrument="acoustic_grand_piano"
quantization={quantization}
onMetricsChange={onMetricsChange}
/>
);
expect(vexflowMocks.addKeySignatureMock).toHaveBeenCalledWith('C');
expect(vexflowMocks.addKeySignatureMock).toHaveBeenCalledWith('G', 'C');
expect(vexflowMocks.applyAccidentalsMock).toHaveBeenCalledWith(expect.any(Array), 'C');
expect(vexflowMocks.applyAccidentalsMock).toHaveBeenCalledWith(expect.any(Array), 'G');
});
it('widens a measure when a key change header is inserted', () => {
const activeRegion = createMockMidiRegion({
startFromBeat: 0,
length: 12,
notes: [],
});
const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 1, 2, 4),
]);
render(
<SheetMusicView
activeRegion={activeRegion}
midiRegions={[activeRegion]}
maxBars={8}
sheetMusicTrackScopeEnabled={false}
timeSignature={{ numerator: 4, denominator: 4 }}
keySignature="C major"
instrument="acoustic_grand_piano"
quantization={quantization}
onMetricsChange={onMetricsChange}
/>
);
const metrics = getLatestMetrics();
expect(metrics[1].widthPx).toBeGreaterThan(metrics[2].widthPx);
});
}); });
+43 -5
View File
@@ -3,6 +3,8 @@ import { Accidental, BarlineType, Beam, Dot, Formatter, Renderer, Stave, StaveNo
import { Playhead } from '../common'; import { Playhead } from '../common';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import type { KeySignature } from '../../core/KGProject'; import type { KeySignature } from '../../core/KGProject';
import { GlobalTrackType } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import type { KGMidiRegion } from '../../core/region/KGMidiRegion'; import type { KGMidiRegion } from '../../core/region/KGMidiRegion';
import type { InstrumentType } from '../../core/track/KGMidiTrack'; import type { InstrumentType } from '../../core/track/KGMidiTrack';
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes'; import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
@@ -11,6 +13,7 @@ import {
getSheetBeatAtPixel, getSheetBeatAtPixel,
buildSheetMeasureModels, buildSheetMeasureModels,
getSheetPlayheadPixel, getSheetPlayheadPixel,
getSheetKeySignatureChangeModifierWidth,
projectKeySignatureToVexFlow, projectKeySignatureToVexFlow,
resolveDurationSpec, resolveDurationSpec,
resolveSheetClef, resolveSheetClef,
@@ -64,12 +67,28 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
}) => { }) => {
const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition); const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition);
const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll); const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll);
const globalTracks = useProjectStore(state => state.globalTracks);
const [metrics, setMetrics] = useState<SheetMeasureMetric[]>([]); const [metrics, setMetrics] = useState<SheetMeasureMetric[]>([]);
const [tiePaths, setTiePaths] = useState<SheetTiePath[]>([]); const [tiePaths, setTiePaths] = useState<SheetTiePath[]>([]);
const headerRef = useRef<HTMLDivElement | null>(null); const headerRef = useRef<HTMLDivElement | null>(null);
const measureHostRefs = useRef<Array<HTMLDivElement | null>>([]); const measureHostRefs = useRef<Array<HTMLDivElement | null>>([]);
const lastDrawSignatureRef = useRef<string | null>(null); const lastDrawSignatureRef = useRef<string | null>(null);
const vexKeySignature = useMemo(() => projectKeySignatureToVexFlow(keySignature), [keySignature]); const signatureRegions = useMemo(() => {
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature);
if (!signatureTrack) {
return [] as KGKeySignatureRegion[];
}
return signatureTrack.getRegions()
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
.sort((left, right) => left.getStartBar() - right.getStartBar());
}, [globalTracks]);
const resolveEffectiveKeySignatureAtBar = useMemo(
() => (barIndex: number): KeySignature => (
signatureRegions.find(region => barIndex >= region.getStartBar() && barIndex < region.getEndBar())?.getKeySignature() ?? keySignature
),
[keySignature, signatureRegions]
);
const startingBarNumber = useMemo(() => ( const startingBarNumber = useMemo(() => (
sheetMusicTrackScopeEnabled sheetMusicTrackScopeEnabled
? 1 ? 1
@@ -88,13 +107,24 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
projectMaxBars: maxBars, projectMaxBars: maxBars,
timeSignature, timeSignature,
quantization, quantization,
defaultKeySignature: keySignature,
resolveKeySignatureAtBar: resolveEffectiveKeySignatureAtBar,
}); });
}, [activeRegion, maxBars, midiRegions, quantization, sheetMusicTrackScopeEnabled, timeSignature]); }, [activeRegion, keySignature, maxBars, midiRegions, quantization, resolveEffectiveKeySignatureAtBar, sheetMusicTrackScopeEnabled, timeSignature]);
const measureWidths = useMemo( const measureWidths = useMemo(
() => measureModels.map((measure, index) => ( () => measureModels.map((measure, index) => (
Math.max( Math.max(
MIN_MEASURE_WIDTH, MIN_MEASURE_WIDTH,
140 + measure.events.length * EVENT_WIDTH + (index === 0 ? FIRST_MEASURE_MODIFIER_WIDTH : 0) 140 +
measure.events.length * EVENT_WIDTH +
(
index === 0
? FIRST_MEASURE_MODIFIER_WIDTH
: getSheetKeySignatureChangeModifierWidth(
measure.keySignature,
measureModels[index - 1]?.keySignature ?? null
)
)
) )
)), )),
[measureModels] [measureModels]
@@ -119,6 +149,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
clef, clef,
instrument, instrument,
keySignature, keySignature,
measureKeySignatures: measureModels.map((measure) => measure.keySignature),
quantization: quantization.raw, quantization: quantization.raw,
numerator: timeSignature.numerator, numerator: timeSignature.numerator,
denominator: timeSignature.denominator, denominator: timeSignature.denominator,
@@ -157,6 +188,8 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
renderer.resize(width, STAFF_HEIGHT); renderer.resize(width, STAFF_HEIGHT);
const context = renderer.getContext(); const context = renderer.getContext();
const showLeadingModifiers = index === 0; const showLeadingModifiers = index === 0;
const measureVexKeySignature = projectKeySignatureToVexFlow(measure.keySignature);
const previousMeasureKeySignature = index > 0 ? measureModels[index - 1]?.keySignature : null;
const staveX = showLeadingModifiers ? 8 : 0; const staveX = showLeadingModifiers ? 8 : 0;
const staveWidth = Math.max(0, width - staveX); const staveWidth = Math.max(0, width - staveX);
const stave = new Stave(staveX, 10, staveWidth); const stave = new Stave(staveX, 10, staveWidth);
@@ -164,8 +197,13 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
stave.setEndBarType(BarlineType.SINGLE); stave.setEndBarType(BarlineType.SINGLE);
if (showLeadingModifiers) { if (showLeadingModifiers) {
stave.addClef(clef); stave.addClef(clef);
stave.addKeySignature(vexKeySignature); stave.addKeySignature(measureVexKeySignature);
stave.addTimeSignature(`${timeSignature.numerator}/${timeSignature.denominator}`); stave.addTimeSignature(`${timeSignature.numerator}/${timeSignature.denominator}`);
} else if (measure.keySignature !== previousMeasureKeySignature) {
stave.addKeySignature(
measureVexKeySignature,
previousMeasureKeySignature ? projectKeySignatureToVexFlow(previousMeasureKeySignature) : undefined
);
} }
stave.setContext(context).draw(); stave.setContext(context).draw();
@@ -176,7 +214,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
}); });
voice.setStrict(false); voice.setStrict(false);
voice.addTickables(notes); voice.addTickables(notes);
Accidental.applyAccidentals([voice], vexKeySignature); Accidental.applyAccidentals([voice], measureVexKeySignature);
const beams = Beam.generateBeams(notes.filter(note => !note.isRest())); const beams = Beam.generateBeams(notes.filter(note => !note.isRest()));
new Formatter().joinVoices([voice]).formatToStave([voice], stave, { stave }); new Formatter().joinVoices([voice]).formatToStave([voice], stave, { stave });
voice.draw(context, stave); voice.draw(context, stave);
@@ -5,6 +5,7 @@ import {
getSheetBeatAtPixel, getSheetBeatAtPixel,
buildSheetMeasureModels, buildSheetMeasureModels,
getSheetPlayheadPixel, getSheetPlayheadPixel,
getSheetKeySignatureChangeModifierWidth,
getSheetQuantizationOptions, getSheetQuantizationOptions,
isDrumInstrument, isDrumInstrument,
parseSheetQuantization, parseSheetQuantization,
@@ -52,8 +53,8 @@ describe('sheetNotation', () => {
it('maps playhead position through variable-width bars', () => { it('maps playhead position through variable-width bars', () => {
const metrics = buildSheetMeasureMetrics([ const metrics = buildSheetMeasureMetrics([
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] }, { barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] }, { barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
], [120, 240]); ], [120, 240]);
expect(getSheetPlayheadPixel(0, metrics)).toBe(0); expect(getSheetPlayheadPixel(0, metrics)).toBe(0);
@@ -74,6 +75,13 @@ describe('sheetNotation', () => {
expect(projectKeySignatureToVexFlow('F# major')).toBe('F#'); expect(projectKeySignatureToVexFlow('F# major')).toBe('F#');
}); });
it('estimates extra width for cancelled naturals and new accidentals on key changes', () => {
expect(getSheetKeySignatureChangeModifierWidth('C major', 'G major')).toBeGreaterThan(0);
expect(getSheetKeySignatureChangeModifierWidth('G major', 'C major')).toBeGreaterThan(0);
expect(getSheetKeySignatureChangeModifierWidth('D major', 'G major')).toBeGreaterThan(0);
expect(getSheetKeySignatureChangeModifierWidth('C major', 'C major')).toBe(0);
});
it('keeps bar-aligned quarter notes in the correct measure model', () => { it('keeps bar-aligned quarter notes in the correct measure model', () => {
const region = createMockMidiRegion({ const region = createMockMidiRegion({
length: 8, length: 8,
@@ -132,11 +140,30 @@ describe('sheetNotation', () => {
it('maps absolute track beats through sheet metrics for full-track mode', () => { it('maps absolute track beats through sheet metrics for full-track mode', () => {
const metrics = buildSheetMeasureMetrics([ const metrics = buildSheetMeasureMetrics([
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] }, { barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] }, { barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
], [120, 240]); ], [120, 240]);
expect(getSheetPlayheadPixel(5, metrics)).toBe(180); expect(getSheetPlayheadPixel(5, metrics)).toBe(180);
expect(getSheetBeatAtPixel(180, metrics)).toBe(5); expect(getSheetBeatAtPixel(180, metrics)).toBe(5);
}); });
it('attaches effective key signatures to sheet measures', () => {
const region = createMockMidiRegion({
startFromBeat: 4,
length: 12,
notes: [createMockMidiNote({ startBeat: 0, endBeat: 1, pitch: 60 })],
});
const measures = buildSheetMeasureModels({
region,
timeSignature: { numerator: 4, denominator: 4 },
quantization: parseSheetQuantization('16,48'),
defaultKeySignature: 'C major',
resolveKeySignatureAtBar: (barIndex) => (barIndex >= 2 ? 'G major' : 'C major'),
});
expect(measures.map((measure) => measure.absoluteBarIndex)).toEqual([1, 2, 3]);
expect(measures.map((measure) => measure.keySignature)).toEqual(['C major', 'G major', 'G major']);
});
}); });
@@ -1,3 +1,4 @@
import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import type { KeySignature } from '../../core/KGProject'; import type { KeySignature } from '../../core/KGProject';
import type { KGMidiNote } from '../../core/midi/KGMidiNote'; import type { KGMidiNote } from '../../core/midi/KGMidiNote';
@@ -30,6 +31,8 @@ export interface BuildSheetNotationOptions {
projectMaxBars?: number; projectMaxBars?: number;
timeSignature: { numerator: number; denominator: number }; timeSignature: { numerator: number; denominator: number };
quantization: SheetQuantization; quantization: SheetQuantization;
defaultKeySignature?: KeySignature;
resolveKeySignatureAtBar?: (barIndex: number) => KeySignature;
} }
interface WorkingEvent { interface WorkingEvent {
@@ -54,6 +57,32 @@ export function projectKeySignatureToVexFlow(keySignature: KeySignature): string
return quality === 'minor' ? `${tonic}m` : tonic; return quality === 'minor' ? `${tonic}m` : tonic;
} }
export function getSheetKeySignatureChangeModifierWidth(
keySignature: KeySignature,
previousKeySignature: KeySignature | null
): number {
if (!previousKeySignature || previousKeySignature === keySignature) {
return 0;
}
const currentEntry = KEY_SIGNATURE_MAP[keySignature];
const previousEntry = KEY_SIGNATURE_MAP[previousKeySignature];
const currentCount = currentEntry.accidentals.length;
const previousCount = previousEntry.accidentals.length;
const differentTypes = (
(currentEntry.sharps > 0 && previousEntry.flats > 0) ||
(currentEntry.flats > 0 && previousEntry.sharps > 0)
);
const cancelledNaturals = differentTypes
? previousCount
: Math.max(0, previousCount - currentCount);
const glyphCount = cancelledNaturals + currentCount;
// Roughly matches the added horizontal space VexFlow needs for
// naturals followed by the new key signature accidentals.
return 24 + glyphCount * 12;
}
export function parseSheetQuantization(value: string): SheetQuantization { export function parseSheetQuantization(value: string): SheetQuantization {
const [primaryText, subdivisionText] = value.split(','); const [primaryText, subdivisionText] = value.split(',');
const primary = Number.parseInt(primaryText, 10); const primary = Number.parseInt(primaryText, 10);
@@ -203,10 +232,13 @@ export function buildSheetMeasureModels({
projectMaxBars, projectMaxBars,
timeSignature, timeSignature,
quantization, quantization,
defaultKeySignature = 'C major',
resolveKeySignatureAtBar,
}: BuildSheetNotationOptions): SheetMeasureModel[] { }: BuildSheetNotationOptions): SheetMeasureModel[] {
const beatsPerBar = timeSignature.numerator; const beatsPerBar = timeSignature.numerator;
const isTrackScope = scope === 'track'; const isTrackScope = scope === 'track';
const timelineStartBeat = isTrackScope ? 0 : 0; const timelineStartBeat = isTrackScope ? 0 : 0;
const regionStartBar = Math.floor(region.getStartFromBeat() / beatsPerBar);
const measureCount = isTrackScope const measureCount = isTrackScope
? Math.max(1, projectMaxBars ?? 1) ? Math.max(1, projectMaxBars ?? 1)
: Math.max(1, Math.ceil(region.getLength() / beatsPerBar)); : Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
@@ -226,8 +258,10 @@ export function buildSheetMeasureModels({
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({ const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
barIndex, barIndex,
absoluteBarIndex: isTrackScope ? barIndex : regionStartBar + barIndex,
startBeat: timelineStartBeat + barIndex * beatsPerBar, startBeat: timelineStartBeat + barIndex * beatsPerBar,
endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar, endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar,
keySignature: resolveKeySignatureAtBar?.(isTrackScope ? barIndex : regionStartBar + barIndex) ?? defaultKeySignature,
events: [], events: [],
})); }));
@@ -1,3 +1,5 @@
import type { KeySignature } from '../../core/KGProject';
export interface SheetMeasureMetric { export interface SheetMeasureMetric {
barIndex: number; barIndex: number;
startBeat: number; startBeat: number;
@@ -24,7 +26,9 @@ export interface SheetDisplayEvent {
export interface SheetMeasureModel { export interface SheetMeasureModel {
barIndex: number; barIndex: number;
absoluteBarIndex: number;
startBeat: number; startBeat: number;
endBeat: number; endBeat: number;
keySignature: KeySignature;
events: SheetDisplayEvent[]; events: SheetDisplayEvent[];
} }
+1 -1
View File
@@ -58,7 +58,7 @@ export class KGProject {
@WithDefault(0) @WithDefault(0)
private projectStructureVersion: number = 0; private projectStructureVersion: number = 0;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 13; public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 14;
@Expose() @Expose()
@Type(() => KGTrack, { @Type(() => KGTrack, {
@@ -0,0 +1,113 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import type { KeySignature } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
findKeySignatureRegionAtBar,
getSongEndBar,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
export class CreateKeySignatureRegionCommand extends KGCommand {
private readonly startBar: number;
private readonly regionId: string;
private createdRegion: KGKeySignatureRegion | null = null;
private previousRegions: KGKeySignatureRegion[] = [];
constructor(startBar: number, regionId?: string) {
super();
this.startBar = startBar;
this.regionId = regionId ?? generateUniqueId('KGKeySignatureRegion');
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found');
}
const existingRegions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(existingRegions, beatsPerBar);
const songEndBar = getSongEndBar(project);
const clampedStartBar = Math.max(0, Math.min(this.startBar, Math.max(0, songEndBar - 1)));
if (existingRegions.length === 0) {
const nextRegions: KGKeySignatureRegion[] = [];
if (clampedStartBar > 0) {
nextRegions.push(new KGKeySignatureRegion(
generateUniqueId('KGKeySignatureRegion'),
track.getId(),
track.getTrackIndex(),
project.getKeySignature(),
0,
clampedStartBar,
beatsPerBar
));
}
this.createdRegion = new KGKeySignatureRegion(
this.regionId,
track.getId(),
track.getTrackIndex(),
project.getKeySignature(),
clampedStartBar,
Math.max(1, songEndBar - clampedStartBar),
beatsPerBar
);
nextRegions.push(this.createdRegion);
track.setRegions(nextRegions);
return;
}
const containingRegion = findKeySignatureRegionAtBar(project, clampedStartBar);
if (!containingRegion) {
throw new Error(`No key signature region covers bar ${clampedStartBar}`);
}
const regionStartBar = containingRegion.getStartBar();
const regionEndBar = containingRegion.getEndBar();
if (clampedStartBar <= regionStartBar || clampedStartBar >= regionEndBar) {
throw new Error(`Bar ${clampedStartBar} is not a valid split point`);
}
containingRegion.setLengthBars(clampedStartBar - regionStartBar, beatsPerBar);
this.createdRegion = new KGKeySignatureRegion(
this.regionId,
track.getId(),
track.getTrackIndex(),
containingRegion.getKeySignature(),
clampedStartBar,
regionEndBar - clampedStartBar,
beatsPerBar
);
track.setRegions([...existingRegions, this.createdRegion].sort((left, right) => left.getStartBar() - right.getStartBar()));
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Create key signature change at bar ${this.startBar + 1}`;
}
public getCreatedRegion(): KGKeySignatureRegion | null {
return this.createdRegion;
}
}
@@ -0,0 +1,145 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
export class DeleteKeySignatureRegionCommand extends KGCommand {
private readonly regionId: string;
private previousRegions: KGKeySignatureRegion[] = [];
private deletedKeySignature = '';
constructor(regionId: string) {
super();
this.regionId = regionId;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found');
}
const regions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
if (targetIndex === -1) {
throw new Error(`Key signature region with ID ${this.regionId} not found`);
}
const targetRegion = regions[targetIndex];
this.deletedKeySignature = targetRegion.getKeySignature();
if (regions.length === 1) {
track.setRegions([]);
return;
}
const nextRegions = [...regions];
const deletedLengthBars = targetRegion.getLengthBars();
if (targetIndex === 0) {
const nextRegion = nextRegions[1];
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
nextRegions.splice(0, 1);
track.setRegions(nextRegions);
return;
}
const previousRegion = nextRegions[targetIndex - 1];
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
nextRegions.splice(targetIndex, 1);
track.setRegions(nextRegions);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Delete key signature "${this.deletedKeySignature || this.regionId}"`;
}
}
export class DeleteMultipleKeySignatureRegionsCommand extends KGCommand {
private readonly regionIds: string[];
private previousRegions: KGKeySignatureRegion[] = [];
constructor(regionIds: string[]) {
super();
this.regionIds = regionIds;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found');
}
const regions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
const remainingIds = new Set(this.regionIds);
let workingRegions = cloneKeySignatureRegions(regions, beatsPerBar);
for (const regionId of this.regionIds) {
const targetIndex = workingRegions.findIndex(region => region.getId() === regionId);
if (targetIndex === -1) {
continue;
}
const deletedRegion = workingRegions[targetIndex];
const deletedLengthBars = deletedRegion.getLengthBars();
remainingIds.delete(regionId);
if (workingRegions.length === 1) {
workingRegions = [];
continue;
}
if (targetIndex === 0) {
const nextRegion = workingRegions[1];
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
workingRegions.splice(0, 1);
continue;
}
const previousRegion = workingRegions[targetIndex - 1];
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
workingRegions.splice(targetIndex, 1);
}
track.setRegions(workingRegions);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return this.regionIds.length === 1 ? 'Delete key signature' : `Delete ${this.regionIds.length} key signatures`;
}
}
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import { CreateKeySignatureRegionCommand } from './CreateKeySignatureRegionCommand';
import { DeleteKeySignatureRegionCommand } from './DeleteKeySignatureRegionCommand';
import { ResizeKeySignatureRegionCommand } from './ResizeKeySignatureRegionCommand';
import { UpdateKeySignatureRegionCommand } from './UpdateKeySignatureRegionCommand';
describe('global key signature region commands', () => {
beforeEach(() => {
const project = new KGProject('Signatures', 8, 0, 120);
const mockCore = KGCore.instance() as unknown as {
getCurrentProject: ReturnType<typeof vi.fn>;
};
mockCore.getCurrentProject.mockReturnValue(project);
});
const getSignatureTrack = () => {
const signatureTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Signature);
if (!signatureTrack) {
throw new Error('Signature track missing in test setup');
}
return signatureTrack;
};
it('creates the first explicit region by splitting the project default coverage', () => {
const command = new CreateKeySignatureRegionCommand(3);
command.execute();
const signatureTrack = getSignatureTrack();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(3);
expect(regions[0].getKeySignature()).toBe('C major');
expect(regions[1].getStartBar()).toBe(3);
expect(regions[1].getLengthBars()).toBe(5);
expect(regions[1].getKeySignature()).toBe('C major');
});
it('creates additional regions by splitting the covered span and inheriting the key', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('left', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
]);
const command = new CreateKeySignatureRegionCommand(5);
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getLengthBars()).toBe(5);
expect(regions[1].getStartBar()).toBe(5);
expect(regions[1].getLengthBars()).toBe(3);
expect(regions[1].getKeySignature()).toBe('C major');
});
it('resizes a shared boundary and keeps the track gapless', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('left', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 4, 4),
new KGKeySignatureRegion('right', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 4, 4, 4),
]);
const command = new ResizeKeySignatureRegionCommand('left', 'end', 6);
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions[0].getLengthBars()).toBe(6);
expect(regions[1].getStartBar()).toBe(6);
expect(regions[1].getLengthBars()).toBe(2);
});
it('deletes a middle region by extending the previous region', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('first', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('middle', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 3, 4),
new KGKeySignatureRegion('last', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'D major', 5, 3, 4),
]);
const command = new DeleteKeySignatureRegionCommand('middle');
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getLengthBars()).toBe(5);
expect(regions[1].getStartBar()).toBe(5);
});
it('deletes the first region by extending the next region leftward', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('first', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('next', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 6, 4),
]);
const command = new DeleteKeySignatureRegionCommand('first');
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(1);
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(8);
});
it('allows deleting the last remaining region', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('only', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
]);
const command = new DeleteKeySignatureRegionCommand('only');
command.execute();
expect(signatureTrack.getRegions()).toHaveLength(0);
command.undo();
expect(signatureTrack.getRegions()).toHaveLength(1);
});
it('updates the region key signature with undo support', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('region', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
]);
const command = new UpdateKeySignatureRegionCommand('region', 'G major');
command.execute();
expect((signatureTrack.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('G major');
command.undo();
expect((signatureTrack.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('C major');
});
});
@@ -0,0 +1,88 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import type { GlobalRegionResizeEdge } from './ResizeGlobalRegionCommand';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
export class ResizeKeySignatureRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly edge: GlobalRegionResizeEdge;
private readonly desiredBar: number;
private previousRegions: KGKeySignatureRegion[] = [];
constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBar: number) {
super();
this.regionId = regionId;
this.edge = edge;
this.desiredBar = desiredBar;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found');
}
const regions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
if (targetIndex === -1) {
throw new Error(`Key signature region with ID ${this.regionId} not found`);
}
const targetRegion = regions[targetIndex];
if (this.edge === 'start') {
if (targetIndex === 0) {
return;
}
const previousRegion = regions[targetIndex - 1];
const targetEndBar = targetRegion.getEndBar();
const clampedBoundaryBar = Math.max(
previousRegion.getStartBar() + 1,
Math.min(this.desiredBar, targetEndBar - 1)
);
previousRegion.setLengthBars(clampedBoundaryBar - previousRegion.getStartBar(), beatsPerBar);
targetRegion.setBarRange(clampedBoundaryBar, targetEndBar - clampedBoundaryBar, beatsPerBar);
return;
}
if (targetIndex === regions.length - 1) {
return;
}
const nextRegion = regions[targetIndex + 1];
const nextRegionEndBar = nextRegion.getEndBar();
const clampedBoundaryBar = Math.max(
targetRegion.getStartBar() + 1,
Math.min(this.desiredBar, nextRegionEndBar - 1)
);
targetRegion.setLengthBars(clampedBoundaryBar - targetRegion.getStartBar(), beatsPerBar);
nextRegion.setBarRange(clampedBoundaryBar, nextRegionEndBar - clampedBoundaryBar, beatsPerBar);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Resize key signature boundary for "${this.regionId}"`;
}
}
@@ -0,0 +1,40 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import type { KeySignature } from '../../KGProject';
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
export class UpdateKeySignatureRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly nextKeySignature: KeySignature;
private previousKeySignature: KeySignature | null = null;
constructor(regionId: string, nextKeySignature: KeySignature) {
super();
this.regionId = regionId;
this.nextKeySignature = nextKeySignature;
}
execute(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGKeySignatureRegion)) {
throw new Error(`Key signature region with ID ${this.regionId} not found`);
}
this.previousKeySignature = result.region.getKeySignature();
result.region.setKeySignature(this.nextKeySignature);
}
undo(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGKeySignatureRegion) || !this.previousKeySignature) {
throw new Error(`Key signature region with ID ${this.regionId} not found during undo`);
}
result.region.setKeySignature(this.previousKeySignature);
}
getDescription(): string {
return `Change key signature to "${this.nextKeySignature}"`;
}
}
+4
View File
@@ -36,10 +36,14 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands // Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand'; export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand'; export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand'; export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand'; export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand'; export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
// Note commands // Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand'; export { CreateNoteCommand } from './note/CreateNoteCommand';
+2
View File
@@ -1,5 +1,6 @@
import { Expose, Type } from 'class-transformer'; import { Expose, Type } from 'class-transformer';
import { KGGlobalRegion } from '../region/KGGlobalRegion'; import { KGGlobalRegion } from '../region/KGGlobalRegion';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion'; import { KGMarkerRegion } from '../region/KGMarkerRegion';
export enum GlobalTrackType { export enum GlobalTrackType {
@@ -32,6 +33,7 @@ export class KGGlobalTrack {
subTypes: [ subTypes: [
{ value: KGGlobalRegion, name: 'KGGlobalRegion' }, { value: KGGlobalRegion, name: 'KGGlobalRegion' },
{ value: KGMarkerRegion, name: 'KGMarkerRegion' }, { value: KGMarkerRegion, name: 'KGMarkerRegion' },
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
], ],
}, },
}) })
+1 -1
View File
@@ -5,7 +5,7 @@ export class KGSignatureTrack extends KGGlobalTrack {
@Expose() @Expose()
protected override __type: string = 'KGSignatureTrack'; protected override __type: string = 'KGSignatureTrack';
constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Signature') { constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Key Signature') {
super(id, trackIndex, GlobalTrackType.Signature, name, []); super(id, trackIndex, GlobalTrackType.Signature, name, []);
this.__type = 'KGSignatureTrack'; this.__type = 'KGSignatureTrack';
} }
+20
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage'; import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
import { KGProject } from '../KGProject'; import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track'; import { GlobalTrackType } from '../global-track';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion'; import { KGMarkerRegion } from '../region/KGMarkerRegion';
import { KGTrack } from '../track/KGTrack'; import { KGTrack } from '../track/KGTrack';
@@ -179,6 +180,25 @@ describe('KGProjectStorage', () => {
expect(loadedMarkerTrack?.getRegions()[0].getName()).toBe('Intro'); expect(loadedMarkerTrack?.getRegions()[0].getName()).toBe('Intro');
}); });
it('preserves key signature regions when saving and loading', async () => {
const project = createTestProject('Signature Song');
const signatureTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
expect(signatureTrack).toBeDefined();
signatureTrack?.addRegion(new KGKeySignatureRegion('signature-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 4, 12, 4));
await storage.save('Signature Song', project);
const loaded = await storage.load('Signature Song');
const loadedSignatureTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
expect(loadedSignatureTrack).toBeDefined();
expect(loadedSignatureTrack?.getRegions()).toHaveLength(1);
expect(loadedSignatureTrack?.getRegions()[0]).toBeInstanceOf(KGKeySignatureRegion);
expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('G major');
expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getStartBar()).toBe(4);
});
it('creates meta.json and media/ directory on save', async () => { it('creates meta.json and media/ directory on save', async () => {
const project = createTestProject('My Song'); const project = createTestProject('My Song');
await storage.save('My Song', project); await storage.save('My Song', project);
@@ -12,6 +12,7 @@ import { upgradeToV10 } from './upgradeToV10';
import { upgradeToV11 } from './upgradeToV11'; import { upgradeToV11 } from './upgradeToV11';
import { upgradeToV12 } from './upgradeToV12'; import { upgradeToV12 } from './upgradeToV12';
import { upgradeToV13 } from './upgradeToV13'; import { upgradeToV13 } from './upgradeToV13';
import { upgradeToV14 } from './upgradeToV14';
/** /**
* Upgrade the given project to the latest structure version, one version at a time. * Upgrade the given project to the latest structure version, one version at a time.
@@ -83,6 +84,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV13(workingProject); workingProject = upgradeToV13(workingProject);
break; break;
} }
case 14: {
workingProject = upgradeToV14(workingProject);
break;
}
default: { default: {
// If an upgrader is missing, throw to prevent loading incompatible structures // If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`); throw new Error(`No upgrader found for project structure version ${nextVersion}`);
@@ -18,6 +18,7 @@ describe('upgradeToV13', () => {
GlobalTrackType.Signature, GlobalTrackType.Signature,
GlobalTrackType.Chord, GlobalTrackType.Chord,
]); ]);
expect(project.getGlobalTracks()[2].getName()).toBe('Key Signature');
}); });
it('runs through the main upgrader path', () => { it('runs through the main upgrader path', () => {
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { KGProject } from '../KGProject';
import { upgradeProjectToLatest } from './KGProjectUpgrader';
import { upgradeToV14 } from './upgradeToV14';
describe('upgradeToV14', () => {
it('marks projects as upgraded even when no signature regions exist yet', () => {
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 13, 1, []);
upgradeToV14(project);
expect(project.getProjectStructureVersion()).toBe(14);
expect(project.getGlobalTracks()).toHaveLength(4);
});
it('runs through the main upgrader path', () => {
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 13, 1, []);
const upgraded = upgradeProjectToLatest(project);
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
import { ensureDefaultGlobalTracks, getSortedKeySignatureRegions } from '../../util/globalTrackUtil';
export function upgradeToV14(project: KGProject): KGProject {
try {
ensureDefaultGlobalTracks(project);
const signatureTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
if (signatureTrack) {
const beatsPerBar = project.getTimeSignature().numerator;
const regions = getSortedKeySignatureRegions(signatureTrack, beatsPerBar);
signatureTrack.setRegions(regions);
}
} finally {
project.setProjectStructureVersion(14);
}
return project;
}
+87
View File
@@ -0,0 +1,87 @@
import { Expose } from 'class-transformer';
import type { KeySignature } from '../KGProject';
import { KGGlobalRegion } from './KGGlobalRegion';
export class KGKeySignatureRegion extends KGGlobalRegion {
@Expose()
protected override __type: string = 'KGKeySignatureRegion';
@Expose()
private keySignature: KeySignature = 'C major';
@Expose()
private startBar: number = 0;
@Expose()
private lengthBars: number = 1;
constructor(
id: string,
trackId: string,
trackIndex: number,
keySignature: KeySignature,
startBar: number = 0,
lengthBars: number = 1,
beatsPerBar: number = 4
) {
super(id, trackId, trackIndex, keySignature, startBar * beatsPerBar, lengthBars * beatsPerBar);
this.__type = 'KGKeySignatureRegion';
this.keySignature = keySignature;
this.startBar = startBar;
this.lengthBars = lengthBars;
this.syncBeatsFromBars(beatsPerBar);
super.setName(keySignature);
}
public getKeySignature(): KeySignature {
return this.keySignature;
}
public setKeySignature(keySignature: KeySignature): void {
this.keySignature = keySignature;
super.setName(keySignature);
}
public getStartBar(): number {
return this.startBar;
}
public getLengthBars(): number {
return this.lengthBars;
}
public getEndBar(): number {
return this.startBar + this.lengthBars;
}
public setStartBar(startBar: number, beatsPerBar: number): void {
this.startBar = startBar;
this.syncBeatsFromBars(beatsPerBar);
}
public setLengthBars(lengthBars: number, beatsPerBar: number): void {
this.lengthBars = lengthBars;
this.syncBeatsFromBars(beatsPerBar);
}
public setBarRange(startBar: number, lengthBars: number, beatsPerBar: number): void {
this.startBar = startBar;
this.lengthBars = lengthBars;
this.syncBeatsFromBars(beatsPerBar);
}
public syncBeatsFromBars(beatsPerBar: number): void {
super.setStartFromBeat(this.startBar * beatsPerBar);
super.setLength(this.lengthBars * beatsPerBar);
}
public syncBarsFromBeats(beatsPerBar: number): void {
this.startBar = Math.floor(this.getStartFromBeat() / beatsPerBar);
this.lengthBars = Math.max(1, Math.round(this.getLength() / beatsPerBar));
super.setName(this.keySignature);
}
public override getCurrentType(): string {
return 'KGKeySignatureRegion';
}
}
+54
View File
@@ -1,10 +1,12 @@
import { KGProject } from '../core/KGProject'; import { KGProject } from '../core/KGProject';
import type { KeySignature } from '../core/KGProject';
import { import {
GlobalTrackType, GlobalTrackType,
KGGlobalTrack, KGGlobalTrack,
createDefaultGlobalTracks, createDefaultGlobalTracks,
} from '../core/global-track'; } from '../core/global-track';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion'; import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
export const DEFAULT_MARKER_REGION_NAME = 'Marker'; export const DEFAULT_MARKER_REGION_NAME = 'Marker';
@@ -83,3 +85,55 @@ export function findMarkerNeighborBounds(
return { minStartBeat, maxEndBeat, nextStartBeat }; return { minStartBeat, maxEndBeat, nextStartBeat };
} }
export function getSongEndBar(project: KGProject): number {
return project.getMaxBars();
}
export function getSortedKeySignatureRegions(track: KGGlobalTrack, beatsPerBar: number): KGKeySignatureRegion[] {
return track.getRegions()
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
.map((region) => {
region.syncBarsFromBeats(beatsPerBar);
region.syncBeatsFromBars(beatsPerBar);
return region;
})
.sort((left, right) => left.getStartBar() - right.getStartBar());
}
export function cloneKeySignatureRegions(regions: KGKeySignatureRegion[], beatsPerBar: number): KGKeySignatureRegion[] {
return regions.map(region => new KGKeySignatureRegion(
region.getId(),
region.getTrackId(),
region.getTrackIndex(),
region.getKeySignature(),
region.getStartBar(),
region.getLengthBars(),
beatsPerBar
));
}
export function findKeySignatureRegionAtBar(project: KGProject, bar: number): KGKeySignatureRegion | null {
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
return null;
}
const beatsPerBar = project.getTimeSignature().numerator;
return getSortedKeySignatureRegions(track, beatsPerBar)
.find(region => bar >= region.getStartBar() && bar < region.getEndBar()) ?? null;
}
export function findKeySignatureRegionAtBeat(project: KGProject, beat: number): KGKeySignatureRegion | null {
const beatsPerBar = project.getTimeSignature().numerator;
const bar = Math.floor(beat / beatsPerBar);
return findKeySignatureRegionAtBar(project, bar);
}
export function getEffectiveKeySignatureAtBeat(project: KGProject, beat: number): KeySignature {
return findKeySignatureRegionAtBeat(project, beat)?.getKeySignature() ?? project.getKeySignature();
}
export function getClampedKeySignatureRegionEndBar(region: KGKeySignatureRegion, maxBars: number): number {
return Math.max(region.getStartBar(), Math.min(region.getEndBar(), maxBars));
}